From 6ad22ee903c2501e244162316065b7798f20aa32 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 27 Nov 2024 14:11:59 +0100 Subject: [PATCH 01/41] Support for `#[Deprecated]` attribute --- src/Analyser/NodeScopeResolver.php | 60 ++++++ src/Internal/DeprecatedAttributeHelper.php | 45 +++++ src/Reflection/ClassReflection.php | 5 +- src/Reflection/EnumCaseReflection.php | 23 ++- src/Reflection/Php/PhpFunctionReflection.php | 6 + src/Reflection/Php/PhpMethodReflection.php | 6 + .../RealClassClassConstantReflection.php | 8 +- .../NativeFunctionReflectionProvider.php | 2 +- .../Annotations/DeprecatedAnnotationsTest.php | 189 ++++++++++++++++++ ...hpFunctionFromParserReflectionRuleTest.php | 111 ++++++++++ .../data/deprecated-attribute-constants.php | 21 ++ .../data/deprecated-attribute-enum.php | 19 ++ .../data/deprecated-attribute-functions.php | 34 ++++ .../data/deprecated-attribute-methods.php | 33 +++ 14 files changed, 555 insertions(+), 7 deletions(-) create mode 100644 src/Internal/DeprecatedAttributeHelper.php create mode 100644 tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php create mode 100644 tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-constants.php create mode 100644 tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-enum.php create mode 100644 tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-functions.php create mode 100644 tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-methods.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a70c0ca5ec..7aa9791928 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -134,6 +134,7 @@ use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\FunctionReflection; +use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\Native\NativeMethodReflection; @@ -525,6 +526,10 @@ private function processStmtNode( $nodeCallback($stmt->returnType, $scope); } + if (!$isDeprecated) { + [$isDeprecated, $deprecatedDescription] = $this->getDeprecatedAttribute($scope, $stmt); + } + $functionScope = $scope->enterFunction( $stmt, $templateTypeMap, @@ -609,6 +614,10 @@ private function processStmtNode( $nodeCallback($stmt->returnType, $scope); } + if (!$isDeprecated) { + [$isDeprecated, $deprecatedDescription] = $this->getDeprecatedAttribute($scope, $stmt); + } + $methodScope = $scope->enterClassMethod( $stmt, $templateTypeMap, @@ -1933,6 +1942,57 @@ static function (Node $node, Scope $scope) use ($nodeCallback): void { return new StatementResult($scope, $hasYield, false, [], $throwPoints, $impurePoints); } + /** + * @return array{bool, string|null} + */ + private function getDeprecatedAttribute(Scope $scope, Node\Stmt\Function_|Node\Stmt\ClassMethod $stmt): array + { + $initializerExprContext = InitializerExprContext::fromStubParameter( + null, + $scope->getFile(), + $stmt, + ); + $isDeprecated = false; + $deprecatedDescription = null; + $deprecatedDescriptionType = null; + foreach ($stmt->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toString() !== 'Deprecated') { + continue; + } + $isDeprecated = true; + $arguments = $attr->args; + foreach ($arguments as $i => $arg) { + $argName = $arg->name; + if ($argName === null) { + if ($i !== 0) { + continue; + } + + $deprecatedDescriptionType = $this->initializerExprTypeResolver->getType($arg->value, $initializerExprContext); + break; + } + + if ($argName->toString() !== 'message') { + continue; + } + + $deprecatedDescriptionType = $this->initializerExprTypeResolver->getType($arg->value, $initializerExprContext); + break; + } + } + } + + if ($deprecatedDescriptionType !== null) { + $constantStrings = $deprecatedDescriptionType->getConstantStrings(); + if (count($constantStrings) === 1) { + $deprecatedDescription = $constantStrings[0]->getValue(); + } + } + + return [$isDeprecated, $deprecatedDescription]; + } + /** * @return ThrowPoint[]|null */ diff --git a/src/Internal/DeprecatedAttributeHelper.php b/src/Internal/DeprecatedAttributeHelper.php new file mode 100644 index 0000000000..8217fa1ef4 --- /dev/null +++ b/src/Internal/DeprecatedAttributeHelper.php @@ -0,0 +1,45 @@ + $attributes + */ + public static function getDeprecatedDescription(array $attributes): ?string + { + $deprecated = ReflectionAttributeHelper::filterAttributesByName($attributes, 'Deprecated'); + foreach ($deprecated as $attr) { + $arguments = $attr->getArguments(); + foreach ($arguments as $i => $arg) { + if (!is_string($arg)) { + continue; + } + + if (is_int($i)) { + if ($i !== 0) { + continue; + } + + return $arg; + } + + if ($i !== 'message') { + continue; + } + + return $arg; + } + } + + return null; + } + +} diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 54555706ee..cd7ca830b3 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -772,9 +772,8 @@ public function getEnumCases(): array if ($case instanceof ReflectionEnumBackedCase) { $valueType = $this->initializerExprTypeResolver->getType($case->getValueExpression(), $initializerExprContext); } - /** @var string $caseName */ $caseName = $case->getName(); - $cases[$caseName] = new EnumCaseReflection($this, $caseName, $valueType); + $cases[$caseName] = new EnumCaseReflection($this, $case, $valueType); } return $this->enumCases = $cases; @@ -800,7 +799,7 @@ public function getEnumCase(string $name): EnumCaseReflection $valueType = $this->initializerExprTypeResolver->getType($case->getValueExpression(), InitializerExprContext::fromClassReflection($this)); } - return new EnumCaseReflection($this, $name, $valueType); + return new EnumCaseReflection($this, $case, $valueType); } public function isClass(): bool diff --git a/src/Reflection/EnumCaseReflection.php b/src/Reflection/EnumCaseReflection.php index 2ce5cc63cf..7c250f0cf5 100644 --- a/src/Reflection/EnumCaseReflection.php +++ b/src/Reflection/EnumCaseReflection.php @@ -2,6 +2,10 @@ namespace PHPStan\Reflection; +use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnumBackedCase; +use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnumUnitCase; +use PHPStan\Internal\DeprecatedAttributeHelper; +use PHPStan\TrinaryLogic; use PHPStan\Type\Type; /** @@ -10,7 +14,7 @@ final class EnumCaseReflection { - public function __construct(private ClassReflection $declaringEnum, private string $name, private ?Type $backingValueType) + public function __construct(private ClassReflection $declaringEnum, private ReflectionEnumUnitCase|ReflectionEnumBackedCase $reflection, private ?Type $backingValueType) { } @@ -21,7 +25,7 @@ public function getDeclaringEnum(): ClassReflection public function getName(): string { - return $this->name; + return $this->reflection->getName(); } public function getBackingValueType(): ?Type @@ -29,4 +33,19 @@ public function getBackingValueType(): ?Type return $this->backingValueType; } + public function isDeprecated(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isDeprecated()); + } + + public function getDeprecatedDescription(): ?string + { + if ($this->reflection->isDeprecated()) { + $attributes = $this->reflection->getBetterReflection()->getAttributes(); + return DeprecatedAttributeHelper::getDeprecatedDescription($attributes); + } + + return null; + } + } diff --git a/src/Reflection/Php/PhpFunctionReflection.php b/src/Reflection/Php/PhpFunctionReflection.php index e8fbc2e824..ea6764ec2e 100644 --- a/src/Reflection/Php/PhpFunctionReflection.php +++ b/src/Reflection/Php/PhpFunctionReflection.php @@ -4,6 +4,7 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionFunction; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionParameter; +use PHPStan\Internal\DeprecatedAttributeHelper; use PHPStan\Parser\Parser; use PHPStan\Parser\VariadicFunctionsVisitor; use PHPStan\Reflection\Assertions; @@ -190,6 +191,11 @@ public function getDeprecatedDescription(): ?string return $this->deprecatedDescription; } + if ($this->reflection->isDeprecated()) { + $attributes = $this->reflection->getBetterReflection()->getAttributes(); + return DeprecatedAttributeHelper::getDeprecatedDescription($attributes); + } + return null; } diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index d32c16e75e..888530f270 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -4,6 +4,7 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionMethod; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionParameter; +use PHPStan\Internal\DeprecatedAttributeHelper; use PHPStan\Parser\Parser; use PHPStan\Parser\VariadicMethodsVisitor; use PHPStan\Reflection\Assertions; @@ -352,6 +353,11 @@ public function getDeprecatedDescription(): ?string return $this->deprecatedDescription; } + if ($this->reflection->isDeprecated()) { + $attributes = $this->reflection->getBetterReflection()->getAttributes(); + return DeprecatedAttributeHelper::getDeprecatedDescription($attributes); + } + return null; } diff --git a/src/Reflection/RealClassClassConstantReflection.php b/src/Reflection/RealClassClassConstantReflection.php index 85d863ba81..f9194090d3 100644 --- a/src/Reflection/RealClassClassConstantReflection.php +++ b/src/Reflection/RealClassClassConstantReflection.php @@ -4,6 +4,7 @@ use PhpParser\Node\Expr; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClassConstant; +use PHPStan\Internal\DeprecatedAttributeHelper; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypehintHelper; @@ -111,7 +112,7 @@ public function isFinal(): bool public function isDeprecated(): TrinaryLogic { - return TrinaryLogic::createFromBoolean($this->isDeprecated); + return TrinaryLogic::createFromBoolean($this->isDeprecated || $this->reflection->isDeprecated()); } public function getDeprecatedDescription(): ?string @@ -120,6 +121,11 @@ public function getDeprecatedDescription(): ?string return $this->deprecatedDescription; } + if ($this->reflection->isDeprecated()) { + $attributes = $this->reflection->getBetterReflection()->getAttributes(); + return DeprecatedAttributeHelper::getDeprecatedDescription($attributes); + } + return null; } diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index 2a94fc4da5..6332238c33 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -57,6 +57,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $reflectionFunctionAdapter = new ReflectionFunction($reflectionFunction); $returnsByReference = TrinaryLogic::createFromBoolean($reflectionFunctionAdapter->returnsReference()); $realFunctionName = $reflectionFunction->getName(); + $isDeprecated = $reflectionFunction->isDeprecated(); if ($reflectionFunction->getFileName() !== null) { $fileName = $reflectionFunction->getFileName(); $docComment = $reflectionFunction->getDocComment(); @@ -66,7 +67,6 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef if ($throwsTag !== null) { $throwType = $throwsTag->getType(); } - $isDeprecated = $reflectionFunction->isDeprecated(); } } } catch (IdentifierNotFound | InvalidIdentifierName) { diff --git a/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php b/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php index 75fbfa4527..ff57846f96 100644 --- a/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php +++ b/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php @@ -10,9 +10,13 @@ use DeprecatedAnnotations\Foo; use DeprecatedAnnotations\FooInterface; use DeprecatedAnnotations\SubBazInterface; +use DeprecatedAttributeConstants\FooWithConstants; +use DeprecatedAttributeMethods\FooWithMethods; use PhpParser\Node\Name; use PHPStan\Analyser\Scope; use PHPStan\Testing\PHPStanTestCase; +use PHPStan\TrinaryLogic; +use const PHP_VERSION_ID; class DeprecatedAnnotationsTest extends PHPStanTestCase { @@ -153,4 +157,189 @@ public function testNotDeprecatedChildMethods(): void $this->assertTrue($reflectionProvider->getClass(Baz::class)->getNativeMethod('superDeprecated')->isDeprecated()->no()); } + public function dataDeprecatedAttributeAboveFunction(): iterable + { + yield [ + 'DeprecatedAttributeFunctions\\notDeprecated', + TrinaryLogic::createNo(), + null, + ]; + yield [ + 'DeprecatedAttributeFunctions\\foo', + TrinaryLogic::createYes(), + null, + ]; + yield [ + 'DeprecatedAttributeFunctions\\fooWithMessage', + TrinaryLogic::createYes(), + 'msg', + ]; + yield [ + 'DeprecatedAttributeFunctions\\fooWithMessage2', + TrinaryLogic::createYes(), + 'msg2', + ]; + yield [ + 'DeprecatedAttributeFunctions\\fooWithConstantMessage', + TrinaryLogic::createYes(), + 'DeprecatedAttributeFunctions\\fooWithConstantMessage', + ]; + } + + /** + * @dataProvider dataDeprecatedAttributeAboveFunction + * + * @param non-empty-string $functionName + */ + public function testDeprecatedAttributeAboveFunction(string $functionName, TrinaryLogic $isDeprecated, ?string $deprecatedDescription): void + { + require_once __DIR__ . '/data/deprecated-attribute-functions.php'; + + $reflectionProvider = $this->createReflectionProvider(); + $function = $reflectionProvider->getFunction(new Name($functionName), null); + $this->assertSame($isDeprecated->describe(), $function->isDeprecated()->describe()); + $this->assertSame($deprecatedDescription, $function->getDeprecatedDescription()); + } + + public function dataDeprecatedAttributeAboveMethod(): iterable + { + yield [ + FooWithMethods::class, + 'notDeprecated', + TrinaryLogic::createNo(), + null, + ]; + yield [ + FooWithMethods::class, + 'foo', + TrinaryLogic::createYes(), + null, + ]; + yield [ + FooWithMethods::class, + 'fooWithMessage', + TrinaryLogic::createYes(), + 'msg', + ]; + yield [ + FooWithMethods::class, + 'fooWithMessage2', + TrinaryLogic::createYes(), + 'msg2', + ]; + } + + /** + * @dataProvider dataDeprecatedAttributeAboveMethod + */ + public function testDeprecatedAttributeAboveMethod(string $className, string $methodName, TrinaryLogic $isDeprecated, ?string $deprecatedDescription): void + { + $reflectionProvider = $this->createReflectionProvider(); + $class = $reflectionProvider->getClass($className); + $method = $class->getNativeMethod($methodName); + $this->assertSame($isDeprecated->describe(), $method->isDeprecated()->describe()); + $this->assertSame($deprecatedDescription, $method->getDeprecatedDescription()); + } + + public function dataDeprecatedAttributeAboveClassConstant(): iterable + { + yield [ + FooWithConstants::class, + 'notDeprecated', + TrinaryLogic::createNo(), + null, + ]; + yield [ + FooWithConstants::class, + 'foo', + TrinaryLogic::createYes(), + null, + ]; + yield [ + FooWithConstants::class, + 'fooWithMessage', + TrinaryLogic::createYes(), + 'msg', + ]; + yield [ + FooWithConstants::class, + 'fooWithMessage2', + TrinaryLogic::createYes(), + 'msg2', + ]; + + if (PHP_VERSION_ID < 80100) { + return; + } + + yield [ + 'DeprecatedAttributeEnum\\EnumWithDeprecatedCases', + 'foo', + TrinaryLogic::createYes(), + null, + ]; + yield [ + 'DeprecatedAttributeEnum\\EnumWithDeprecatedCases', + 'fooWithMessage', + TrinaryLogic::createYes(), + 'msg', + ]; + yield [ + 'DeprecatedAttributeEnum\\EnumWithDeprecatedCases', + 'fooWithMessage2', + TrinaryLogic::createYes(), + 'msg2', + ]; + } + + /** + * @dataProvider dataDeprecatedAttributeAboveClassConstant + */ + public function testDeprecatedAttributeAboveClassConstant(string $className, string $constantName, TrinaryLogic $isDeprecated, ?string $deprecatedDescription): void + { + $reflectionProvider = $this->createReflectionProvider(); + $class = $reflectionProvider->getClass($className); + $constant = $class->getConstant($constantName); + $this->assertSame($isDeprecated->describe(), $constant->isDeprecated()->describe()); + $this->assertSame($deprecatedDescription, $constant->getDeprecatedDescription()); + } + + public function dataDeprecatedAttributeAboveEnumCase(): iterable + { + yield [ + 'DeprecatedAttributeEnum\\EnumWithDeprecatedCases', + 'foo', + TrinaryLogic::createYes(), + null, + ]; + yield [ + 'DeprecatedAttributeEnum\\EnumWithDeprecatedCases', + 'fooWithMessage', + TrinaryLogic::createYes(), + 'msg', + ]; + yield [ + 'DeprecatedAttributeEnum\\EnumWithDeprecatedCases', + 'fooWithMessage2', + TrinaryLogic::createYes(), + 'msg2', + ]; + } + + /** + * @dataProvider dataDeprecatedAttributeAboveEnumCase + */ + public function testDeprecatedAttributeAboveEnumCase(string $className, string $caseName, TrinaryLogic $isDeprecated, ?string $deprecatedDescription): void + { + if (PHP_VERSION_ID < 80100) { + $this->markTestSkipped('Test requires PHP 8.1.'); + } + + $reflectionProvider = $this->createReflectionProvider(); + $class = $reflectionProvider->getClass($className); + $case = $class->getEnumCase($caseName); + $this->assertSame($isDeprecated->describe(), $case->isDeprecated()->describe()); + $this->assertSame($deprecatedDescription, $case->getDeprecatedDescription()); + } + } diff --git a/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php b/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php new file mode 100644 index 0000000000..ce520ef7aa --- /dev/null +++ b/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php @@ -0,0 +1,111 @@ + + */ +class DeprecatedAttributePhpFunctionFromParserReflectionRuleTest extends RuleTestCase +{ + + /** + * @return Rule + */ + protected function getRule(): Rule + { + return new /** @implements Rule */ class implements Rule { + + public function getNodeType(): string + { + return Node\Stmt::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node instanceof InFunctionNode) { + $reflection = $node->getFunctionReflection(); + } elseif ($node instanceof InClassMethodNode) { + $reflection = $node->getMethodReflection(); + } else { + return []; + } + + if (!$reflection->isDeprecated()->yes()) { + return [ + RuleErrorBuilder::message('Not deprecated')->identifier('tests.notDeprecated')->build(), + ]; + } + + $description = $reflection->getDeprecatedDescription(); + if ($description === null) { + return [ + RuleErrorBuilder::message('Deprecated')->identifier('tests.deprecated')->build(), + ]; + } + + return [ + RuleErrorBuilder::message(sprintf('Deprecated: %s', $description))->identifier('tests.deprecated')->build(), + ]; + } + + }; + } + + public function testFunctionRule(): void + { + $this->analyse([__DIR__ . '/data/deprecated-attribute-functions.php'], [ + [ + 'Not deprecated', + 7, + ], + [ + 'Deprecated', + 12, + ], + [ + 'Deprecated: msg', + 18, + ], + [ + 'Deprecated: msg2', + 24, + ], + [ + 'Deprecated: DeprecatedAttributeFunctions\\fooWithConstantMessage', + 30, + ], + ]); + } + + public function testMethodRule(): void + { + $this->analyse([__DIR__ . '/data/deprecated-attribute-methods.php'], [ + [ + 'Not deprecated', + 10, + ], + [ + 'Deprecated', + 15, + ], + [ + 'Deprecated: msg', + 21, + ], + [ + 'Deprecated: msg2', + 27, + ], + ]); + } + +} diff --git a/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-constants.php b/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-constants.php new file mode 100644 index 0000000000..6de5cc39d6 --- /dev/null +++ b/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-constants.php @@ -0,0 +1,21 @@ += 8.1 + +namespace DeprecatedAttributeEnum; + +use Deprecated; + +enum EnumWithDeprecatedCases +{ + + #[Deprecated] + case foo; + + #[Deprecated('msg')] + case fooWithMessage; + + #[Deprecated(since: '1.0', message: 'msg2')] + case fooWithMessage2; + +} diff --git a/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-functions.php b/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-functions.php new file mode 100644 index 0000000000..a7325b8fc3 --- /dev/null +++ b/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-functions.php @@ -0,0 +1,34 @@ + Date: Wed, 11 Dec 2024 09:02:00 +0100 Subject: [PATCH 02/41] Detect hooked properties outside of constructor --- Makefile | 1 + .../Classes/InvalidPromotedPropertiesRule.php | 7 ++++++- .../InvalidPromotedPropertiesRuleTest.php | 15 +++++++++++++ .../data/invalid-hooked-properties.php | 21 +++++++++++++++++++ 4 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Classes/data/invalid-hooked-properties.php diff --git a/Makefile b/Makefile index d8566bfdb0..407333c955 100644 --- a/Makefile +++ b/Makefile @@ -89,6 +89,7 @@ lint: --exclude tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php \ --exclude tests/PHPStan/Rules/Classes/data/bug-12281.php \ --exclude tests/PHPStan/Rules/Traits/data/bug-12281.php \ + --exclude tests/PHPStan/Rules/Classes/data/invalid-hooked-properties.php \ src tests cs: diff --git a/src/Rules/Classes/InvalidPromotedPropertiesRule.php b/src/Rules/Classes/InvalidPromotedPropertiesRule.php index 22434786a8..6472285f76 100644 --- a/src/Rules/Classes/InvalidPromotedPropertiesRule.php +++ b/src/Rules/Classes/InvalidPromotedPropertiesRule.php @@ -31,7 +31,12 @@ public function processNode(Node $node, Scope $scope): array $hasPromotedProperties = false; foreach ($node->getParams() as $param) { - if ($param->flags === 0) { + if ($param->flags !== 0) { + $hasPromotedProperties = true; + break; + } + + if ($param->hooks === []) { continue; } diff --git a/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php b/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php index de80ea8f95..2ce3e7e268 100644 --- a/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Classes/InvalidPromotedPropertiesRuleTest.php @@ -104,4 +104,19 @@ public function testBug9577(): void $this->analyse([__DIR__ . '/data/bug-9577.php'], []); } + public function testHooks(): void + { + if (PHP_VERSION_ID < 80100) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->phpVersion = 80100; + $this->analyse([__DIR__ . '/data/invalid-hooked-properties.php'], [ + [ + 'Promoted properties can be in constructor only.', + 9, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Classes/data/invalid-hooked-properties.php b/tests/PHPStan/Rules/Classes/data/invalid-hooked-properties.php new file mode 100644 index 0000000000..e0933face1 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/invalid-hooked-properties.php @@ -0,0 +1,21 @@ + Date: Wed, 11 Dec 2024 09:17:36 +0100 Subject: [PATCH 03/41] Invoke virtual ClassPropertyNode for hooked promoted properties without visibility modifier --- src/Analyser/NodeScopeResolver.php | 2 +- .../Rules/Properties/PropertyInClassRuleTest.php | 4 ++++ .../data/hooked-properties-without-bodies-in-class.php | 10 ++++++++++ 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7aa9791928..c9a73a68e4 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -645,7 +645,7 @@ private function processStmtNode( $isFromTrait = $stmt->getAttribute('originalTraitMethodName') === '__construct'; if ($isFromTrait || $stmt->name->toLowerString() === '__construct') { foreach ($stmt->params as $param) { - if ($param->flags === 0) { + if ($param->flags === 0 && $param->hooks === []) { continue; } diff --git a/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php b/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php index 0b2ca5ba09..f6fde1e51c 100644 --- a/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php +++ b/tests/PHPStan/Rules/Properties/PropertyInClassRuleTest.php @@ -50,6 +50,10 @@ public function testPhp84AndHookedPropertiesWithoutBodiesInClass(): void 'Non-abstract properties cannot include hooks without bodies.', 9, ], + [ + 'Non-abstract properties cannot include hooks without bodies.', + 15, + ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php b/tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php index dc839f0d2c..fb0edcc3ce 100644 --- a/tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php +++ b/tests/PHPStan/Rules/Properties/data/hooked-properties-without-bodies-in-class.php @@ -8,3 +8,13 @@ class AbstractPerson public string $lastName { get; set; } } + +class PromotedHookedPropertyWithoutVisibility +{ + + public function __construct(mixed $test { get; }) + { + + } + +} From acf6d0331e7b77facf97a989238080e8c8133f05 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 11 Dec 2024 10:01:00 +0100 Subject: [PATCH 04/41] Process property hooks * They get a new virtual node InPropertyHookNode * Existing virtual nodes like InFunctionNode or InClassMethodNode are NOT invoked for them * But rules for FunctionLike node are invoked for property hooks * `Scope::getFunction()` returns PhpMethodFromParserNodeReflection inside property hooks --- src/Analyser/MutatingScope.php | 85 ++++++++++ src/Analyser/NodeScopeResolver.php | 116 ++++++++++++-- src/Node/InPropertyHookNode.php | 53 +++++++ .../PhpFunctionFromParserNodeReflection.php | 9 +- .../Php/PhpMethodFromParserNodeReflection.php | 74 +++++++-- .../PHPStan/Analyser/nsrt/property-hooks.php | 150 ++++++++++++++++++ .../Variables/DefinedVariableRuleTest.php | 30 ++++ .../Rules/Variables/data/property-hooks.php | 54 +++++++ 8 files changed, 547 insertions(+), 24 deletions(-) create mode 100644 src/Node/InPropertyHookNode.php create mode 100644 tests/PHPStan/Analyser/nsrt/property-hooks.php create mode 100644 tests/PHPStan/Rules/Variables/data/property-hooks.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index f883c95578..bfa07d782d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -7,6 +7,7 @@ use Generator; use PhpParser\Node; use PhpParser\Node\Arg; +use PhpParser\Node\ComplexType; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\BinaryOp; @@ -21,6 +22,7 @@ use PhpParser\Node\Expr\New_; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; use PhpParser\Node\InterpolatedStringPart; use PhpParser\Node\Name; use PhpParser\Node\Name\FullyQualified; @@ -2961,6 +2963,7 @@ public function enterClassMethod( new PhpMethodFromParserNodeReflection( $this->getClassReflection(), $classMethod, + null, $this->getFile(), $templateTypeMap, $this->getRealParameterTypes($classMethod), @@ -2986,6 +2989,88 @@ public function enterClassMethod( ); } + /** + * @param Type[] $phpDocParameterTypes + */ + public function enterPropertyHook( + Node\PropertyHook $hook, + string $propertyName, + Identifier|Name|ComplexType|null $nativePropertyTypeNode, + ?Type $phpDocPropertyType, + array $phpDocParameterTypes, + ?Type $throwType, + ?string $phpDocComment, + ): self + { + if (!$this->isInClass()) { + throw new ShouldNotHappenException(); + } + + $phpDocParameterTypes = array_map(fn (Type $type): Type => $this->transformStaticType(TemplateTypeHelper::toArgument($type)), $phpDocParameterTypes); + + $hookName = $hook->name->toLowerString(); + if ($hookName === 'set') { + if ($hook->params === []) { + $hook = clone $hook; + $hook->params = [ + new Node\Param(new Variable('value'), null, $nativePropertyTypeNode), + ]; + } + + $firstParam = $hook->params[0] ?? null; + if ( + $firstParam !== null + && $phpDocPropertyType !== null + && $firstParam->var instanceof Variable + && is_string($firstParam->var->name) + ) { + $valueParamPhpDocType = $phpDocParameterTypes[$firstParam->var->name] ?? null; + if ($valueParamPhpDocType === null) { + $phpDocParameterTypes[$firstParam->var->name] = $this->transformStaticType(TemplateTypeHelper::toArgument($phpDocPropertyType)); + } + } + + $realReturnType = new VoidType(); + $phpDocReturnType = null; + } elseif ($hookName === 'get') { + $realReturnType = $this->getFunctionType($nativePropertyTypeNode, false, false); + $phpDocReturnType = $phpDocPropertyType !== null ? $this->transformStaticType(TemplateTypeHelper::toArgument($phpDocPropertyType)) : null; + } else { + throw new ShouldNotHappenException(); + } + + $realParameterTypes = $this->getRealParameterTypes($hook); + + return $this->enterFunctionLike( + new PhpMethodFromParserNodeReflection( + $this->getClassReflection(), + $hook, + $propertyName, + $this->getFile(), + TemplateTypeMap::createEmpty(), + $realParameterTypes, + $phpDocParameterTypes, + [], + $realReturnType, + $phpDocReturnType, + $throwType, + null, + false, + false, + false, + false, + true, + Assertions::createEmpty(), + null, + $phpDocComment, + [], + [], + [], + ), + true, + ); + } + private function transformStaticType(Type $type): Type { return TypeTraverser::map($type, function (Type $type, callable $traverse): Type { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index c9a73a68e4..4dd117aab8 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -10,6 +10,7 @@ use PhpParser\Node; use PhpParser\Node\Arg; use PhpParser\Node\AttributeGroup; +use PhpParser\Node\ComplexType; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\ArrayDimFetch; @@ -35,6 +36,7 @@ use PhpParser\Node\Expr\StaticPropertyFetch; use PhpParser\Node\Expr\Ternary; use PhpParser\Node\Expr\Variable; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Stmt\Break_; use PhpParser\Node\Stmt\Class_; @@ -98,6 +100,7 @@ use PHPStan\Node\InClosureNode; use PHPStan\Node\InForeachNode; use PHPStan\Node\InFunctionNode; +use PHPStan\Node\InPropertyHookNode; use PHPStan\Node\InstantiationCallableNode; use PHPStan\Node\InTraitNode; use PHPStan\Node\InvalidateExprNode; @@ -642,6 +645,8 @@ private function processStmtNode( throw new ShouldNotHappenException(); } + $classReflection = $scope->getClassReflection(); + $isFromTrait = $stmt->getAttribute('originalTraitMethodName') === '__construct'; if ($isFromTrait || $stmt->name->toLowerString() === '__construct') { foreach ($stmt->params as $param) { @@ -659,7 +664,7 @@ private function processStmtNode( $nodeCallback(new ClassPropertyNode( $param->var->name, $param->flags, - $param->type !== null ? ParserNodeTypeToPHPStanType::resolve($param->type, $scope->getClassReflection()) : null, + $param->type !== null ? ParserNodeTypeToPHPStanType::resolve($param->type, $classReflection) : null, null, $phpDoc, $phpDocParameterTypes[$param->var->name] ?? null, @@ -668,10 +673,19 @@ private function processStmtNode( $param, false, $scope->isInTrait(), - $scope->getClassReflection()->isReadOnly(), + $classReflection->isReadOnly(), false, - $scope->getClassReflection(), + $classReflection, ), $methodScope); + $this->processPropertyHooks( + $stmt, + $param->type, + $phpDocParameterTypes[$param->var->name] ?? null, + $param->var->name, + $param->hooks, + $scope, + $nodeCallback, + ); $methodScope = $methodScope->assignExpression(new PropertyInitializationExpr($param->var->name), new MixedType(), new MixedType()); } } @@ -681,7 +695,7 @@ private function processStmtNode( if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { throw new ShouldNotHappenException(); } - $nodeCallback(new InClassMethodNode($scope->getClassReflection(), $methodReflection, $stmt), $methodScope); + $nodeCallback(new InClassMethodNode($classReflection, $methodReflection, $stmt), $methodScope); } if ($stmt->stmts !== null) { @@ -730,8 +744,6 @@ private function processStmtNode( $gatheredReturnStatements[] = new ReturnStatement($scope, $node); }, StatementContext::createTopLevel()); - $classReflection = $scope->getClassReflection(); - $methodReflection = $methodScope->getFunction(); if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { throw new ShouldNotHappenException(); @@ -893,29 +905,38 @@ private function processStmtNode( $impurePoints = []; $this->processAttributeGroups($stmt, $stmt->attrGroups, $scope, $nodeCallback); + $nativePropertyType = $stmt->type !== null ? ParserNodeTypeToPHPStanType::resolve($stmt->type, $scope->getClassReflection()) : null; + + [,,,,,,,,,,,,$isReadOnly, $docComment, ,,,$varTags, $isAllowedPrivateMutation] = $this->getPhpDocs($scope, $stmt); + $phpDocType = null; + if (isset($varTags[0]) && count($varTags) === 1) { + $phpDocType = $varTags[0]->getType(); + } + foreach ($stmt->props as $prop) { $nodeCallback($prop, $scope); if ($prop->default !== null) { $this->processExprNode($stmt, $prop->default, $scope, $nodeCallback, ExpressionContext::createDeep()); } - [,,,,,,,,,,,,$isReadOnly, $docComment, ,,,$varTags, $isAllowedPrivateMutation] = $this->getPhpDocs($scope, $stmt); + if (!$scope->isInClass()) { throw new ShouldNotHappenException(); } $propertyName = $prop->name->toString(); - $phpDocType = null; - if (isset($varTags[0]) && count($varTags) === 1) { - $phpDocType = $varTags[0]->getType(); - } elseif (isset($varTags[$propertyName])) { - $phpDocType = $varTags[$propertyName]->getType(); + + if ($phpDocType === null) { + if (isset($varTags[$propertyName])) { + $phpDocType = $varTags[$propertyName]->getType(); + } } + $propStmt = clone $stmt; $propStmt->setAttributes($prop->getAttributes()); $nodeCallback( new ClassPropertyNode( $propertyName, $stmt->flags, - $stmt->type !== null ? ParserNodeTypeToPHPStanType::resolve($stmt->type, $scope->getClassReflection()) : null, + $nativePropertyType, $prop->default, $docComment, $phpDocType, @@ -932,6 +953,21 @@ private function processStmtNode( ); } + if (count($stmt->hooks) > 0) { + if (!isset($propertyName)) { + throw new ShouldNotHappenException('Property name should be known when analysing hooks.'); + } + $this->processPropertyHooks( + $stmt, + $stmt->type, + $phpDocType, + $propertyName, + $stmt->hooks, + $scope, + $nodeCallback, + ); + } + if ($stmt->type !== null) { $nodeCallback($stmt->type, $scope); } @@ -4614,6 +4650,60 @@ private function processAttributeGroups( } } + /** + * @param Node\PropertyHook[] $hooks + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processPropertyHooks( + Node\Stmt $stmt, + Identifier|Name|ComplexType|null $nativeTypeNode, + ?Type $phpDocType, + string $propertyName, + array $hooks, + MutatingScope $scope, + callable $nodeCallback, + ): void + { + if (!$scope->isInClass()) { + throw new ShouldNotHappenException(); + } + + $classReflection = $scope->getClassReflection(); + + foreach ($hooks as $hook) { + $nodeCallback($hook, $scope); + $this->processAttributeGroups($stmt, $hook->attrGroups, $scope, $nodeCallback); + + [, $phpDocParameterTypes,,,, $phpDocThrowType,,,,,,,, $phpDocComment] = $this->getPhpDocs($scope, $hook); + + foreach ($hook->params as $param) { + $this->processParamNode($stmt, $param, $scope, $nodeCallback); + } + + $hookScope = $scope->enterPropertyHook( + $hook, + $propertyName, + $nativeTypeNode, + $phpDocType, + $phpDocParameterTypes, + $phpDocThrowType, + $phpDocComment, + ); + $hookReflection = $hookScope->getFunction(); + if (!$hookReflection instanceof PhpMethodFromParserNodeReflection) { + throw new ShouldNotHappenException(); + } + $nodeCallback(new InPropertyHookNode($classReflection, $hookReflection, $hook), $hookScope); + + if ($hook->body instanceof Expr) { + $this->processExprNode($stmt, $hook->body, $hookScope, $nodeCallback, ExpressionContext::createTopLevel()); + } elseif (is_array($hook->body)) { + $this->processStmtNodes($stmt, $hook->body, $hookScope, $nodeCallback, StatementContext::createTopLevel()); + } + + } + } + /** * @param MethodReflection|FunctionReflection|null $calleeReflection * @param callable(Node $node, Scope $scope): void $nodeCallback diff --git a/src/Node/InPropertyHookNode.php b/src/Node/InPropertyHookNode.php new file mode 100644 index 0000000000..0484c2568f --- /dev/null +++ b/src/Node/InPropertyHookNode.php @@ -0,0 +1,53 @@ +getAttributes()); + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getMethodReflection(): PhpMethodFromParserNodeReflection + { + return $this->hookReflection; + } + + public function getOriginalNode(): Node\PropertyHook + { + return $this->originalNode; + } + + public function getType(): string + { + return 'PHPStan_Node_InPropertyHookNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index 1d157476df..809e32f888 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -30,14 +30,14 @@ class PhpFunctionFromParserNodeReflection implements FunctionReflection, ExtendedParametersAcceptor { - /** @var Function_|ClassMethod */ + /** @var Function_|ClassMethod|Node\PropertyHook */ private Node\FunctionLike $functionLike; /** @var list|null */ private ?array $variants = null; /** - * @param Function_|ClassMethod $functionLike + * @param Function_|ClassMethod|Node\PropertyHook $functionLike * @param Type[] $realParameterTypes * @param Type[] $phpDocParameterTypes * @param Type[] $realParameterDefaultValues @@ -86,6 +86,11 @@ public function getName(): string return $this->functionLike->name->name; } + if (!$this->functionLike instanceof Function_) { + // PropertyHook is handled in PhpMethodFromParserNodeReflection subclass + throw new ShouldNotHappenException(); + } + if ($this->functionLike->namespacedName === null) { throw new ShouldNotHappenException(); } diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 8cd703c8b2..52ab304f98 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -2,6 +2,7 @@ namespace PHPStan\Reflection\Php; +use PhpParser\Modifiers; use PhpParser\Node; use PhpParser\Node\Stmt\ClassMethod; use PHPStan\Reflection\Assertions; @@ -9,6 +10,7 @@ use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\MissingMethodFromReflectionException; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; @@ -21,6 +23,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\VoidType; use function in_array; +use function sprintf; use function strtolower; /** @@ -38,7 +41,8 @@ final class PhpMethodFromParserNodeReflection extends PhpFunctionFromParserNodeR */ public function __construct( private ClassReflection $declaringClass, - private ClassMethod $classMethod, + private ClassMethod|Node\PropertyHook $classMethod, + private ?string $hookForProperty, string $fileName, TemplateTypeMap $templateTypeMap, array $realParameterTypes, @@ -61,6 +65,14 @@ public function __construct( array $phpDocClosureThisTypeParameters, ) { + if ($this->classMethod instanceof Node\PropertyHook) { + if ($this->hookForProperty === null) { + throw new ShouldNotHappenException('Hook was provided but property was not'); + } + } elseif ($this->hookForProperty !== null) { + throw new ShouldNotHappenException('Hooked property was provided but hook was not'); + } + $name = strtolower($classMethod->name->name); if (in_array($name, ['__construct', '__destruct', '__unset', '__wakeup', '__clone'], true)) { $realReturnType = new VoidType(); @@ -131,36 +143,75 @@ public function getPrototype(): ClassMemberReflection } } - private function getClassMethod(): ClassMethod + private function getClassMethod(): ClassMethod|Node\PropertyHook { - /** @var Node\Stmt\ClassMethod $functionLike */ + /** @var Node\Stmt\ClassMethod|Node\PropertyHook $functionLike */ $functionLike = $this->getFunctionLike(); return $functionLike; } + public function getName(): string + { + $function = $this->getFunctionLike(); + if (!$function instanceof Node\PropertyHook) { + return parent::getName(); + } + + if ($this->hookForProperty === null) { + throw new ShouldNotHappenException('Hook was provided but property was not'); + } + + return sprintf('$%s::%s', $this->hookForProperty, $function->name->toString()); + } + public function isStatic(): bool { - return $this->getClassMethod()->isStatic(); + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return false; + } + + return $method->isStatic(); } public function isPrivate(): bool { - return $this->getClassMethod()->isPrivate(); + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return false; + } + + return $method->isPrivate(); } public function isPublic(): bool { - return $this->getClassMethod()->isPublic(); + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return true; + } + + return $method->isPublic(); } public function isFinal(): TrinaryLogic { - return TrinaryLogic::createFromBoolean($this->classMethod->isFinal() || $this->isFinal); + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return TrinaryLogic::createFromBoolean((bool) ($method->flags & Modifiers::FINAL)); + } + + return TrinaryLogic::createFromBoolean($method->isFinal() || $this->isFinal); } public function isFinalByKeyword(): TrinaryLogic { - return TrinaryLogic::createFromBoolean($this->classMethod->isFinal()); + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return TrinaryLogic::createFromBoolean((bool) ($method->flags & Modifiers::FINAL)); + } + + return TrinaryLogic::createFromBoolean($method->isFinal()); } public function isBuiltin(): bool @@ -180,7 +231,12 @@ public function returnsByReference(): TrinaryLogic public function isAbstract(): TrinaryLogic { - return TrinaryLogic::createFromBoolean($this->getClassMethod()->isAbstract()); + $method = $this->getClassMethod(); + if ($method instanceof Node\PropertyHook) { + return TrinaryLogic::createFromBoolean($method->body === null); + } + + return TrinaryLogic::createFromBoolean($method->isAbstract()); } public function hasSideEffects(): TrinaryLogic diff --git a/tests/PHPStan/Analyser/nsrt/property-hooks.php b/tests/PHPStan/Analyser/nsrt/property-hooks.php new file mode 100644 index 0000000000..b169e7e7a3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/property-hooks.php @@ -0,0 +1,150 @@ += 8.4 + +declare(strict_types=1); + +namespace PropertyHooksTypes; + +use function PHPStan\Testing\assertType; + +class Foo +{ + + public int $i { + set { + assertType('int', $value); + } + } + + public int $j { + set (int $val) { + assertType('int', $val); + } + } + + public int $k { + set (int|string $val) { + assertType('int|string', $val); + } + } + + /** @var array */ + public array $l { + set { + assertType('array', $value); + } + } + + /** @var array */ + public array $m { + set (array $val) { + assertType('array', $val); + } + } + + public int $n { + /** @param int|array $val */ + set (int|array $val) { + assertType('array|int', $val); + } + } + +} + +class FooShort +{ + + public int $i { + set => assertType('int', $value); + } + + public int $j { + set (int $val) => assertType('int', $val); + } + + public int $k { + set (int|string $val) => assertType('int|string', $val); + } + + /** @var array */ + public array $l { + set => assertType('array', $value); + } + + /** @var array */ + public array $m { + set (array $val) => assertType('array', $val); + } + + public int $n { + /** @param int|array $val */ + set (int|array $val) => assertType('array|int', $val); + } + +} + +class FooConstructor +{ + + public function __construct( + public int $i { + set { + assertType('int', $value); + } + }, + public int $j { + set (int $val) { + assertType('int', $val); + } + }, + public int $k { + set (int|string $val) { + assertType('int|string', $val); + } + }, + /** @var array */ + public array $l { + set { + assertType('array', $value); + } + }, + /** @var array */ + public array $m { + set (array $val) { + assertType('array', $val); + } + }, + public int $n { + /** @param int|array $val */ + set (int|array $val) { + assertType('array|int', $val); + } + }, + ) { + + } + +} + +class FooConstructorWithParam +{ + + /** + * @param array $l + * @param array $m + */ + public function __construct( + public array $l { + set { + assertType('array', $value); + } + }, + public array $m { + set (array $val) { + assertType('array', $val); + } + }, + ) { + + } + +} diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index 94f0b0ffeb..17aa83b245 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1068,4 +1068,34 @@ public function testBug10228(): void $this->analyse([__DIR__ . '/data/bug-10228.php'], []); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = true; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/property-hooks.php'], [ + [ + 'Undefined variable: $val', + 16, + ], + [ + 'Undefined variable: $value', + 28, + ], + [ + 'Undefined variable: $val', + 43, + ], + [ + 'Undefined variable: $value', + 51, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/property-hooks.php b/tests/PHPStan/Rules/Variables/data/property-hooks.php new file mode 100644 index 0000000000..1fc6f744b2 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/property-hooks.php @@ -0,0 +1,54 @@ += 8.4 + +namespace PropertyHooksVariables; + +class Foo +{ + + public int $i { + set { + $this->i = $value + 10; + } + } + + public int $iErr { + set { + $this->iErr = $val + 10; + } + } + + public int $j { + set (int $val) { + $this->j = $val + 10; + } + } + + public int $jErr { + set (int $val) { + $this->jErr = $value + 10; + } + } + +} + + +class FooShort +{ + + public int $i { + set => $value + 10; + } + + public int $iErr { + set => $val + 10; + } + + public int $j { + set (int $val) => $val + 10; + } + + public int $jErr { + set (int $val) => $value + 10; + } + +} From 6f9582513ec93cdc651023ce4aae9d1d98e48368 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 13 Dec 2024 10:56:14 +0100 Subject: [PATCH 05/41] Fix PHPDocs with generics in property hooks --- conf/config.neon | 5 + src/Analyser/NodeScopeResolver.php | 6 + src/Parser/PropertyHookNameVisitor.php | 60 ++++++++ src/Parser/SimpleParser.php | 2 + src/Type/FileTypeMapper.php | 66 +++++++-- .../PHPStan/Analyser/nsrt/property-hooks.php | 136 ++++++++++++++++++ tests/PHPStan/Parser/CleaningParserTest.php | 1 + 7 files changed, 266 insertions(+), 10 deletions(-) create mode 100644 src/Parser/PropertyHookNameVisitor.php diff --git a/conf/config.neon b/conf/config.neon index d77e889531..1501a7a253 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -318,6 +318,11 @@ services: tags: - phpstan.parser.richParserNodeVisitor + - + class: PHPStan\Parser\PropertyHookNameVisitor + tags: + - phpstan.parser.richParserNodeVisitor + - class: PHPStan\Node\Printer\ExprPrinter diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 4dd117aab8..98de22733b 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -123,6 +123,7 @@ use PHPStan\Parser\ClosureArgVisitor; use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\Parser; +use PHPStan\Parser\PropertyHookNameVisitor; use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\PhpDocInheritanceResolver; use PHPStan\PhpDoc\ResolvedPhpDocBlock; @@ -6243,6 +6244,11 @@ public function getPhpDocs(Scope $scope, Node\FunctionLike|Node\Stmt\Property $n } } elseif ($node instanceof Node\Stmt\Function_) { $functionName = trim($scope->getNamespace() . '\\' . $node->name->name, '\\'); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + if ($propertyName !== null) { + $functionName = sprintf('$%s::%s', $propertyName, $node->name->toString()); + } } if ($docComment !== null && $resolvedPhpDoc === null) { diff --git a/src/Parser/PropertyHookNameVisitor.php b/src/Parser/PropertyHookNameVisitor.php new file mode 100644 index 0000000000..5a49a70915 --- /dev/null +++ b/src/Parser/PropertyHookNameVisitor.php @@ -0,0 +1,60 @@ +hooks) === 0) { + return null; + } + + $propertyName = null; + foreach ($node->props as $prop) { + $propertyName = $prop->name->toString(); + break; + } + + if (!isset($propertyName)) { + return null; + } + + foreach ($node->hooks as $hook) { + $hook->setAttribute(self::ATTRIBUTE_NAME, $propertyName); + } + + return $node; + } + + if ($node instanceof Node\Param) { + if (count($node->hooks) === 0) { + return null; + } + if (!$node->var instanceof Node\Expr\Variable) { + return null; + } + if (!is_string($node->var->name)) { + return null; + } + + foreach ($node->hooks as $hook) { + $hook->setAttribute(self::ATTRIBUTE_NAME, $node->var->name); + } + + return $node; + } + + return null; + } + +} diff --git a/src/Parser/SimpleParser.php b/src/Parser/SimpleParser.php index 8fbd112742..71bab19964 100644 --- a/src/Parser/SimpleParser.php +++ b/src/Parser/SimpleParser.php @@ -17,6 +17,7 @@ public function __construct( private NameResolver $nameResolver, private VariadicMethodsVisitor $variadicMethodsVisitor, private VariadicFunctionsVisitor $variadicFunctionsVisitor, + private PropertyHookNameVisitor $propertyHookNameVisitor, ) { } @@ -52,6 +53,7 @@ public function parseString(string $sourceCode): array $nodeTraverser->addVisitor($this->nameResolver); $nodeTraverser->addVisitor($this->variadicMethodsVisitor); $nodeTraverser->addVisitor($this->variadicFunctionsVisitor); + $nodeTraverser->addVisitor($this->propertyHookNameVisitor); /** @var array */ return $nodeTraverser->traverse($nodes); diff --git a/src/Type/FileTypeMapper.php b/src/Type/FileTypeMapper.php index 9a14e3aec4..3cc5e6c62e 100644 --- a/src/Type/FileTypeMapper.php +++ b/src/Type/FileTypeMapper.php @@ -9,6 +9,7 @@ use PHPStan\Broker\AnonymousClassNameHelper; use PHPStan\File\FileHelper; use PHPStan\Parser\Parser; +use PHPStan\Parser\PropertyHookNameVisitor; use PHPStan\PhpDoc\PhpDocNodeResolver; use PHPStan\PhpDoc\PhpDocStringResolver; use PHPStan\PhpDoc\ResolvedPhpDocBlock; @@ -279,6 +280,11 @@ function (Node $node) use ($fileName, $lookForTrait, &$traitFound, $traitMethodA } } elseif ($node instanceof Node\Stmt\Function_) { $functionStack[] = ltrim(sprintf('%s\\%s', $namespace, $node->name->name), '\\'); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + if ($propertyName !== null) { + $functionStack[] = sprintf('$%s::%s', $propertyName, $node->name->toString()); + } } $className = $classStack[count($classStack) - 1] ?? null; @@ -291,6 +297,17 @@ function (Node $node) use ($fileName, $lookForTrait, &$traitFound, $traitMethodA $phpDocNodeMap[$nameScopeKey] = $this->phpDocStringResolver->resolve($docComment); } + return null; + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + if ($propertyName !== null) { + $docComment = GetLastDocComment::forNode($node); + if ($docComment !== null) { + $nameScopeKey = $this->getNameScopeKey($originalClassFileName, $className, $lookForTrait, $functionName); + $phpDocNodeMap[$nameScopeKey] = $this->phpDocStringResolver->resolve($docComment); + } + } + return null; } @@ -376,6 +393,15 @@ static function (Node $node) use (&$namespace, &$functionStack, &$classStack): v } array_pop($functionStack); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + if ($propertyName !== null) { + if (count($functionStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($functionStack); + } } }, ); @@ -476,6 +502,11 @@ function (Node $node) use ($fileName, $lookForTrait, $phpDocNodeMap, &$traitFoun } } elseif ($node instanceof Node\Stmt\Function_) { $functionStack[] = ltrim(sprintf('%s\\%s', $namespace, $node->name->name), '\\'); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + if ($propertyName !== null) { + $functionStack[] = sprintf('$%s::%s', $propertyName, $node->name->toString()); + } } $className = $classStack[count($classStack) - 1] ?? null; @@ -483,6 +514,7 @@ function (Node $node) use ($fileName, $lookForTrait, $phpDocNodeMap, &$traitFoun $nameScopeKey = $this->getNameScopeKey($originalClassFileName, $className, $lookForTrait, $functionName); if ($node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) { + // property hook skipped on purpose, it does not support @template if (array_key_exists($nameScopeKey, $phpDocNodeMap)) { $phpDocNode = $phpDocNodeMap[$nameScopeKey]; $typeMapStack[] = function () use ($namespace, $uses, $className, $lookForTrait, $functionName, $phpDocNode, $typeMapStack, $typeAliasStack, $constUses): TemplateTypeMap { @@ -512,16 +544,20 @@ function (Node $node) use ($fileName, $lookForTrait, $phpDocNodeMap, &$traitFoun $typeAliasesMap = $typeAliasStack[count($typeAliasStack) - 1] ?? []; if ( - $node instanceof Node\Stmt - && !$node instanceof Node\Stmt\Namespace_ - && !$node instanceof Node\Stmt\Declare_ - && !$node instanceof Node\Stmt\Use_ - && !$node instanceof Node\Stmt\GroupUse - && !$node instanceof Node\Stmt\TraitUse - && !$node instanceof Node\Stmt\TraitUseAdaptation - && !$node instanceof Node\Stmt\InlineHTML - && !($node instanceof Node\Stmt\Expression && $node->expr instanceof Node\Expr\Include_) - && !array_key_exists($nameScopeKey, $nameScopeMap) + ( + $node instanceof Node\PropertyHook + || ( + $node instanceof Node\Stmt + && !$node instanceof Node\Stmt\Namespace_ + && !$node instanceof Node\Stmt\Declare_ + && !$node instanceof Node\Stmt\Use_ + && !$node instanceof Node\Stmt\GroupUse + && !$node instanceof Node\Stmt\TraitUse + && !$node instanceof Node\Stmt\TraitUseAdaptation + && !$node instanceof Node\Stmt\InlineHTML + && !($node instanceof Node\Stmt\Expression && $node->expr instanceof Node\Expr\Include_) + ) + ) && !array_key_exists($nameScopeKey, $nameScopeMap) ) { $nameScopeMap[$nameScopeKey] = static fn (): NameScope => new NameScope( $namespace, @@ -537,6 +573,7 @@ function (Node $node) use ($fileName, $lookForTrait, $phpDocNodeMap, &$traitFoun } if ($node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\ClassMethod || $node instanceof Node\Stmt\Function_) { + // property hook skipped on purpose, it does not support @template if (array_key_exists($nameScopeKey, $phpDocNodeMap)) { return self::POP_TYPE_MAP_STACK; } @@ -704,6 +741,15 @@ static function (Node $node, $callbackResult) use (&$namespace, &$functionStack, } array_pop($functionStack); + } elseif ($node instanceof Node\PropertyHook) { + $propertyName = $node->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + if ($propertyName !== null) { + if (count($functionStack) === 0) { + throw new ShouldNotHappenException(); + } + + array_pop($functionStack); + } } if ($callbackResult !== self::POP_TYPE_MAP_STACK) { return; diff --git a/tests/PHPStan/Analyser/nsrt/property-hooks.php b/tests/PHPStan/Analyser/nsrt/property-hooks.php index b169e7e7a3..0e49e0e981 100644 --- a/tests/PHPStan/Analyser/nsrt/property-hooks.php +++ b/tests/PHPStan/Analyser/nsrt/property-hooks.php @@ -148,3 +148,139 @@ public function __construct( } } + +/** + * @template T of \stdClass + */ +class FooGenerics +{ + + /** @var array */ + public array $m { + set (array $val) { + assertType('array', $val); + } + } + + public int $n { + /** @param int|array $val */ + set (int|array $val) { + assertType('array|int', $val); + } + } + +} + +/** + * @template T of \stdClass + */ +class FooGenericsConstructor +{ + + public function __construct( + /** @var array */ + public array $l { + set { + assertType('array', $value); + } + }, + /** @var array */ + public array $m { + set (array $val) { + assertType('array', $val); + } + }, + public int $n { + /** @param int|array $val */ + set (int|array $val) { + assertType('array|int', $val); + } + }, + ) { + + } + +} + +/** + * @template T of \stdClass + */ +class FooGenericsConstructor2 +{ + + /** + * @param array $l + * @param array $m + */ + public function __construct( + public array $l { + set { + assertType('array', $value); + } + }, + public array $m { + set (array $val) { + assertType('array', $val); + } + }, + public int $n { + /** @param int|array $val */ + set (int|array $val) { + assertType('array|int', $val); + } + }, + ) { + + } + +} + +class FooGenericsConstructorWithT +{ + + /** + * @template T of \stdClass + */ + public function __construct( + /** @var array */ + public array $l { + set { + assertType('array', $value); + } + }, + /** @var array */ + public array $m { + set (array $val) { + assertType('array', $val); + } + }, + ) { + + } + +} + +class FooGenericsConstructorWithT2 +{ + + /** + * @template T of \stdClass + * @param array $l + * @param array $m + */ + public function __construct( + public array $l { + set { + assertType('array', $value); + } + }, + public array $m { + set (array $val) { + assertType('array', $val); + } + }, + ) { + + } + +} diff --git a/tests/PHPStan/Parser/CleaningParserTest.php b/tests/PHPStan/Parser/CleaningParserTest.php index 835486fdc2..e0afccabb7 100644 --- a/tests/PHPStan/Parser/CleaningParserTest.php +++ b/tests/PHPStan/Parser/CleaningParserTest.php @@ -70,6 +70,7 @@ public function testParse( new NameResolver(), new VariadicMethodsVisitor(), new VariadicFunctionsVisitor(), + new PropertyHookNameVisitor(), ), new PhpVersion($phpVersionId), ); From a6b230f76c6b09616da7c221683c0d80181baef7 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Dec 2024 13:52:03 +0100 Subject: [PATCH 06/41] Adjust method's ReturnTypeRule for property hooks --- .../Php/PhpMethodFromParserNodeReflection.php | 32 +++++++ src/Rules/Methods/ReturnTypeRule.php | 32 ++++--- .../Rules/Methods/ReturnTypeRuleTest.php | 37 +++++++++ .../Methods/data/property-hooks-return.php | 83 +++++++++++++++++++ 4 files changed, 172 insertions(+), 12 deletions(-) create mode 100644 tests/PHPStan/Rules/Methods/data/property-hooks-return.php diff --git a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php index 52ab304f98..af7d9a80f4 100644 --- a/src/Reflection/Php/PhpMethodFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpMethodFromParserNodeReflection.php @@ -164,6 +164,38 @@ public function getName(): string return sprintf('$%s::%s', $this->hookForProperty, $function->name->toString()); } + /** + * @phpstan-assert-if-true !null $this->getHookedPropertyName() + * @phpstan-assert-if-true !null $this->getPropertyHookName() + */ + public function isPropertyHook(): bool + { + return $this->hookForProperty !== null; + } + + public function getHookedPropertyName(): ?string + { + return $this->hookForProperty; + } + + /** + * @return 'get'|'set'|null + */ + public function getPropertyHookName(): ?string + { + $function = $this->getFunctionLike(); + if (!$function instanceof Node\PropertyHook) { + return null; + } + + $name = $function->name->toLowerString(); + if (!in_array($name, ['get', 'set'], true)) { + throw new ShouldNotHappenException(sprintf('Unknown property hook: %s', $name)); + } + + return $name; + } + public function isStatic(): bool { $method = $this->getClassMethod(); diff --git a/src/Rules/Methods/ReturnTypeRule.php b/src/Rules/Methods/ReturnTypeRule.php index 9f851ce9c6..58abdef6a6 100644 --- a/src/Rules/Methods/ReturnTypeRule.php +++ b/src/Rules/Methods/ReturnTypeRule.php @@ -21,6 +21,7 @@ use function count; use function sprintf; use function strtolower; +use function ucfirst; /** * @implements Rule @@ -52,6 +53,17 @@ public function processNode(Node $node, Scope $scope): array return []; } + if ($method->isPropertyHook()) { + $methodDescription = sprintf( + '%s hook for property %s::$%s', + ucfirst($method->getPropertyHookName()), + $method->getDeclaringClass()->getDisplayName(), + $method->getHookedPropertyName(), + ); + } else { + $methodDescription = sprintf('Method %s::%s()', $method->getDeclaringClass()->getDisplayName(), $method->getName()); + } + $returnType = $method->getReturnType(); $errors = $this->returnTypeCheck->checkReturnType( $scope, @@ -59,24 +71,20 @@ public function processNode(Node $node, Scope $scope): array $node->expr, $node, sprintf( - 'Method %s::%s() should return %%s but empty return statement found.', - $method->getDeclaringClass()->getDisplayName(), - $method->getName(), + '%s should return %%s but empty return statement found.', + $methodDescription, ), sprintf( - 'Method %s::%s() with return type void returns %%s but should not return anything.', - $method->getDeclaringClass()->getDisplayName(), - $method->getName(), + '%s with return type void returns %%s but should not return anything.', + $methodDescription, ), sprintf( - 'Method %s::%s() should return %%s but returns %%s.', - $method->getDeclaringClass()->getDisplayName(), - $method->getName(), + '%s should return %%s but returns %%s.', + $methodDescription, ), sprintf( - 'Method %s::%s() should never return but return statement found.', - $method->getDeclaringClass()->getDisplayName(), - $method->getName(), + '%s should never return but return statement found.', + $methodDescription, ), $method->isGenerator(), ); diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index 1fd11fe100..c524382174 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1101,4 +1101,41 @@ public function testBug12223(): void $this->analyse([__DIR__ . '/data/bug-12223.php'], []); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/property-hooks-return.php'], [ + [ + 'Get hook for property PropertyHooksReturn\Foo::$i should return int but returns string.', + 11, + ], + [ + 'Set hook for property PropertyHooksReturn\Foo::$i with return type void returns int but should not return anything.', + 21, + ], + [ + 'Get hook for property PropertyHooksReturn\Foo::$s should return non-empty-string but returns \'\'.', + 29, + ], + [ + 'Get hook for property PropertyHooksReturn\GenericFoo::$a should return T of PropertyHooksReturn\Foo but returns PropertyHooksReturn\Foo.', + 48, + 'Type PropertyHooksReturn\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Get hook for property PropertyHooksReturn\GenericFoo::$b should return T of PropertyHooksReturn\Foo but returns PropertyHooksReturn\Foo.', + 63, + 'Type PropertyHooksReturn\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Get hook for property PropertyHooksReturn\GenericFoo::$c should return T of PropertyHooksReturn\Foo but returns PropertyHooksReturn\Foo.', + 73, + 'Type PropertyHooksReturn\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/property-hooks-return.php b/tests/PHPStan/Rules/Methods/data/property-hooks-return.php new file mode 100644 index 0000000000..206298551e --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/property-hooks-return.php @@ -0,0 +1,83 @@ += 8.4 + +namespace PropertyHooksReturn; + +class Foo +{ + + public int $i { + get { + if (rand(0, 1)) { + return 'foo'; + } + + return 1; + } + set { + if (rand(0, 1)) { + return; + } + + return 1; + } + } + + /** @var non-empty-string */ + public string $s { + get { + if (rand(0, 1)) { + return ''; + } + + return 'foo'; + } + } + +} + +/** + * @template T of Foo + */ +class GenericFoo +{ + + /** @var T */ + public Foo $a { + get { + if (rand(0, 1)) { + return new Foo(); + } + + return $this->a; + } + } + + /** + * @param T $c + */ + public function __construct( + /** @var T */ + public Foo $b { + get { + if (rand(0, 1)) { + return new Foo(); + } + + return $this->b; + } + }, + + public Foo $c { + get { + if (rand(0, 1)) { + return new Foo(); + } + + return $this->c; + } + } + ) + { + } + +} From 6aa0e445617f798d39f22011855d33562086a0c5 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Dec 2024 21:45:03 +0100 Subject: [PATCH 07/41] ShortGetPropertyHookReturnTypeRule - level 3 --- conf/config.level3.neon | 1 + src/Node/InPropertyHookNode.php | 2 +- .../ShortGetPropertyHookReturnTypeRule.php | 74 +++++++++++++++++++ ...ShortGetPropertyHookReturnTypeRuleTest.php | 56 ++++++++++++++ .../data/short-get-property-hook-return.php | 69 +++++++++++++++++ 5 files changed, 201 insertions(+), 1 deletion(-) create mode 100644 src/Rules/Properties/ShortGetPropertyHookReturnTypeRule.php create mode 100644 tests/PHPStan/Rules/Properties/ShortGetPropertyHookReturnTypeRuleTest.php create mode 100644 tests/PHPStan/Rules/Properties/data/short-get-property-hook-return.php diff --git a/conf/config.level3.neon b/conf/config.level3.neon index 250d545f15..31e065bf6f 100644 --- a/conf/config.level3.neon +++ b/conf/config.level3.neon @@ -20,6 +20,7 @@ rules: - PHPStan\Rules\Properties\ReadOnlyByPhpDocPropertyAssignRule - PHPStan\Rules\Properties\ReadOnlyPropertyAssignRefRule - PHPStan\Rules\Properties\ReadOnlyByPhpDocPropertyAssignRefRule + - PHPStan\Rules\Properties\ShortGetPropertyHookReturnTypeRule - PHPStan\Rules\Properties\TypesAssignedToPropertiesRule - PHPStan\Rules\Variables\ParameterOutAssignedTypeRule - PHPStan\Rules\Variables\ParameterOutExecutionEndTypeRule diff --git a/src/Node/InPropertyHookNode.php b/src/Node/InPropertyHookNode.php index 0484c2568f..b27899949d 100644 --- a/src/Node/InPropertyHookNode.php +++ b/src/Node/InPropertyHookNode.php @@ -27,7 +27,7 @@ public function getClassReflection(): ClassReflection return $this->classReflection; } - public function getMethodReflection(): PhpMethodFromParserNodeReflection + public function getHookReflection(): PhpMethodFromParserNodeReflection { return $this->hookReflection; } diff --git a/src/Rules/Properties/ShortGetPropertyHookReturnTypeRule.php b/src/Rules/Properties/ShortGetPropertyHookReturnTypeRule.php new file mode 100644 index 0000000000..1651ddfdcd --- /dev/null +++ b/src/Rules/Properties/ShortGetPropertyHookReturnTypeRule.php @@ -0,0 +1,74 @@ + + */ +final class ShortGetPropertyHookReturnTypeRule implements Rule +{ + + public function __construct(private FunctionReturnTypeCheck $returnTypeCheck) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + // return statements in long property hook bodies are checked by Methods\ReturnTypeRule + // short set property hook type is checked by TypesAssignedToPropertiesRule + $hookReflection = $node->getHookReflection(); + if ($hookReflection->getPropertyHookName() !== 'get') { + return []; + } + + $originalHookNode = $node->getOriginalNode(); + $hookBody = $originalHookNode->body; + if (!$hookBody instanceof Node\Expr) { + return []; + } + + $methodDescription = sprintf( + 'Get hook for property %s::$%s', + $hookReflection->getDeclaringClass()->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ); + + $returnType = $hookReflection->getReturnType(); + + return $this->returnTypeCheck->checkReturnType( + $scope, + $returnType, + $hookBody, + $node, + sprintf( + '%s should return %%s but empty return statement found.', + $methodDescription, + ), + sprintf( + '%s with return type void returns %%s but should not return anything.', + $methodDescription, + ), + sprintf( + '%s should return %%s but returns %%s.', + $methodDescription, + ), + sprintf( + '%s should never return but return statement found.', + $methodDescription, + ), + $hookReflection->isGenerator(), + ); + } + +} diff --git a/tests/PHPStan/Rules/Properties/ShortGetPropertyHookReturnTypeRuleTest.php b/tests/PHPStan/Rules/Properties/ShortGetPropertyHookReturnTypeRuleTest.php new file mode 100644 index 0000000000..0f4190b433 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/ShortGetPropertyHookReturnTypeRuleTest.php @@ -0,0 +1,56 @@ + + */ +final class ShortGetPropertyHookReturnTypeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ShortGetPropertyHookReturnTypeRule( + new FunctionReturnTypeCheck(new RuleLevelHelper($this->createReflectionProvider(), true, false, true, true, false, false)) + ); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/short-get-property-hook-return.php'], [ + [ + 'Get hook for property ShortGetPropertyHookReturn\Foo::$i should return int but returns string.', + 9, + ], + [ + 'Get hook for property ShortGetPropertyHookReturn\Foo::$s should return non-empty-string but returns \'\'.', + 18, + ], + [ + 'Get hook for property ShortGetPropertyHookReturn\GenericFoo::$a should return T of ShortGetPropertyHookReturn\Foo but returns ShortGetPropertyHookReturn\Foo.', + 36, + 'Type ShortGetPropertyHookReturn\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Get hook for property ShortGetPropertyHookReturn\GenericFoo::$b should return T of ShortGetPropertyHookReturn\Foo but returns ShortGetPropertyHookReturn\Foo.', + 50, + 'Type ShortGetPropertyHookReturn\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Get hook for property ShortGetPropertyHookReturn\GenericFoo::$c should return T of ShortGetPropertyHookReturn\Foo but returns ShortGetPropertyHookReturn\Foo.', + 59, + 'Type ShortGetPropertyHookReturn\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/short-get-property-hook-return.php b/tests/PHPStan/Rules/Properties/data/short-get-property-hook-return.php new file mode 100644 index 0000000000..9271b944bc --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/short-get-property-hook-return.php @@ -0,0 +1,69 @@ += 8.4 + +namespace ShortGetPropertyHookReturn; + +class Foo +{ + + public int $i { + get => 'foo'; + } + + public int $i2 { + get => 1; + } + + /** @var non-empty-string */ + public string $s { + get => ''; + } + + /** @var non-empty-string */ + public string $s2 { + get => 'foo'; + } + +} + +/** + * @template T of Foo + */ +class GenericFoo +{ + + /** @var T */ + public Foo $a { + get => new Foo(); + } + + /** @var T */ + public Foo $a2 { + get => $this->a2; + } + + /** + * @param T $c + */ + public function __construct( + /** @var T */ + public Foo $b { + get => new Foo(); + }, + + /** @var T */ + public Foo $b2 { + get => $this->b2; + }, + + public Foo $c { + get => new Foo(); + }, + + public Foo $c2 { + get => $this->c2; + } + ) + { + } + +} From aeec07710de49af98f6d53dd6a4f29d0b0b1ca77 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 14 Dec 2024 22:02:41 +0100 Subject: [PATCH 08/41] Invoke PropertyAssignNode in short set property hook --- src/Analyser/NodeScopeResolver.php | 1 + .../TypesAssignedToPropertiesRuleTest.php | 34 +++++++++ .../data/short-set-property-hook-assign.php | 69 +++++++++++++++++++ 3 files changed, 104 insertions(+) create mode 100644 tests/PHPStan/Rules/Properties/data/short-set-property-hook-assign.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 98de22733b..0056af9f3f 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4698,6 +4698,7 @@ private function processPropertyHooks( if ($hook->body instanceof Expr) { $this->processExprNode($stmt, $hook->body, $hookScope, $nodeCallback, ExpressionContext::createTopLevel()); + $nodeCallback(new PropertyAssignNode(new PropertyFetch(new Variable('this'), $propertyName, $hook->body->getAttributes()), $hook->body, false), $hookScope); } elseif (is_array($hook->body)) { $this->processStmtNodes($stmt, $hook->body, $hookScope, $nodeCallback, StatementContext::createTopLevel()); } diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index dede3e9211..061a8af510 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -688,4 +688,38 @@ public function testBug12131(): void ]); } + public function testShortBodySetHook(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->checkExplicitMixed = true; + $this->analyse([__DIR__ . '/data/short-set-property-hook-assign.php'], [ + [ + 'Property ShortSetPropertyHookAssign\Foo::$i (int) does not accept string.', + 9, + ], + [ + 'Property ShortSetPropertyHookAssign\Foo::$s (non-empty-string) does not accept \'\'.', + 18, + ], + [ + 'Property ShortSetPropertyHookAssign\GenericFoo::$a (T of ShortSetPropertyHookAssign\Foo) does not accept ShortSetPropertyHookAssign\Foo.', + 36, + 'Type ShortSetPropertyHookAssign\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Property ShortSetPropertyHookAssign\GenericFoo::$b (T of ShortSetPropertyHookAssign\Foo) does not accept ShortSetPropertyHookAssign\Foo.', + 50, + 'Type ShortSetPropertyHookAssign\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Property ShortSetPropertyHookAssign\GenericFoo::$c (T of ShortSetPropertyHookAssign\Foo) does not accept ShortSetPropertyHookAssign\Foo.', + 59, + 'Type ShortSetPropertyHookAssign\Foo is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/short-set-property-hook-assign.php b/tests/PHPStan/Rules/Properties/data/short-set-property-hook-assign.php new file mode 100644 index 0000000000..0e305bf104 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/short-set-property-hook-assign.php @@ -0,0 +1,69 @@ += 8.4 + +namespace ShortSetPropertyHookAssign; + +class Foo +{ + + public int $i { + set => 'foo'; + } + + public int $i2 { + set => 1; + } + + /** @var non-empty-string */ + public string $s { + set => ''; + } + + /** @var non-empty-string */ + public string $s2 { + set => 'foo'; + } + +} + +/** + * @template T of Foo + */ +class GenericFoo +{ + + /** @var T */ + public Foo $a { + set => new Foo(); + } + + /** @var T */ + public Foo $a2 { + set => $this->a2; + } + + /** + * @param T $c + */ + public function __construct( + /** @var T */ + public Foo $b { + set => new Foo(); + }, + + /** @var T */ + public Foo $b2 { + set => $this->b2; + }, + + public Foo $c { + set => new Foo(); + }, + + public Foo $c2 { + set => $this->c2; + } + ) + { + } + +} From ec4035d3ef0cae815c7462bfbcda1c7e27a33f56 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 15 Dec 2024 15:23:22 +0100 Subject: [PATCH 09/41] Set checkUninitializedProperties to false in UnusedPrivatePropertyRuleTest --- .../Rules/DeadCode/UnusedPrivatePropertyRuleTest.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php index 7650418141..0a5bb7eff3 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php @@ -22,6 +22,8 @@ class UnusedPrivatePropertyRuleTest extends RuleTestCase /** @var string[] */ private array $alwaysReadTags; + private bool $checkUninitializedProperties = false; + protected function getRule(): Rule { return new UnusedPrivatePropertyRule( @@ -55,7 +57,7 @@ public function isInitialized(PropertyReflection $property, string $propertyName ]), $this->alwaysWrittenTags, $this->alwaysReadTags, - true, + $this->checkUninitializedProperties, ); } @@ -63,6 +65,7 @@ public function testRule(): void { $this->alwaysWrittenTags = []; $this->alwaysReadTags = []; + $this->checkUninitializedProperties = true; $tip = 'See: https://phpstan.org/developing-extensions/always-read-written-properties'; @@ -236,6 +239,7 @@ public function testBug5337(): void { $this->alwaysWrittenTags = []; $this->alwaysReadTags = []; + $this->checkUninitializedProperties = true; $this->analyse([__DIR__ . '/data/bug-5337.php'], []); } From df2db6d6f83293f25abd5dd24d82801323076c35 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 15 Dec 2024 14:52:32 +0100 Subject: [PATCH 10/41] Fix UnusedPrivatePropertyRule in regard to property hooks --- .../DeadCode/UnusedPrivatePropertyRule.php | 33 +++++- .../UnusedPrivatePropertyRuleTest.php | 40 +++++++ .../data/property-hooks-unused-property.php | 112 ++++++++++++++++++ 3 files changed, 179 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Rules/DeadCode/data/property-hooks-unused-property.php diff --git a/src/Rules/DeadCode/UnusedPrivatePropertyRule.php b/src/Rules/DeadCode/UnusedPrivatePropertyRule.php index b41185b5c9..137bd8f00e 100644 --- a/src/Rules/DeadCode/UnusedPrivatePropertyRule.php +++ b/src/Rules/DeadCode/UnusedPrivatePropertyRule.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Scope; use PHPStan\Node\ClassPropertiesNode; use PHPStan\Node\Property\PropertyRead; +use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Rules\Properties\ReadWritePropertiesExtensionProvider; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -14,6 +15,7 @@ use function array_key_exists; use function array_map; use function count; +use function is_string; use function sprintf; use function str_contains; @@ -113,11 +115,29 @@ public function processNode(Node $node, Scope $scope): array } foreach ($node->getPropertyUsages() as $usage) { + $usageScope = $usage->getScope(); $fetch = $usage->getFetch(); if ($fetch->name instanceof Node\Identifier) { - $propertyNames = [$fetch->name->toString()]; + $propertyName = $fetch->name->toString(); + $propertyNames = [$propertyName]; + if ( + $usageScope->getFunction() !== null + && $fetch instanceof Node\Expr\PropertyFetch + && $fetch->var instanceof Node\Expr\Variable + && is_string($fetch->var->name) + && $fetch->var->name === 'this' + ) { + $methodReflection = $usageScope->getFunction(); + if ( + $methodReflection instanceof PhpMethodFromParserNodeReflection + && $methodReflection->isPropertyHook() + && $methodReflection->getHookedPropertyName() === $propertyName + ) { + continue; + } + } } else { - $propertyNameType = $usage->getScope()->getType($fetch->name); + $propertyNameType = $usageScope->getType($fetch->name); $strings = $propertyNameType->getConstantStrings(); if (count($strings) === 0) { // handle subtractions of a dynamic property fetch @@ -134,13 +154,14 @@ public function processNode(Node $node, Scope $scope): array $propertyNames = array_map(static fn (ConstantStringType $type): string => $type->getValue(), $strings); } + if ($fetch instanceof Node\Expr\PropertyFetch) { - $fetchedOnType = $usage->getScope()->getType($fetch->var); + $fetchedOnType = $usageScope->getType($fetch->var); } else { if ($fetch->class instanceof Node\Name) { - $fetchedOnType = $usage->getScope()->resolveTypeByName($fetch->class); + $fetchedOnType = $usageScope->resolveTypeByName($fetch->class); } else { - $fetchedOnType = $usage->getScope()->getType($fetch->class); + $fetchedOnType = $usageScope->getType($fetch->class); } } @@ -148,7 +169,7 @@ public function processNode(Node $node, Scope $scope): array if (!array_key_exists($propertyName, $properties)) { continue; } - $propertyReflection = $usage->getScope()->getPropertyReflection($fetchedOnType, $propertyName); + $propertyReflection = $usageScope->getPropertyReflection($fetchedOnType, $propertyName); if ($propertyReflection === null) { if (!$classType->isSuperTypeOf($fetchedOnType)->no()) { if ($usage instanceof PropertyRead) { diff --git a/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php b/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php index 0a5bb7eff3..c56a986f1c 100644 --- a/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/UnusedPrivatePropertyRuleTest.php @@ -347,4 +347,44 @@ public function testBug11802(): void ]); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $tip = 'See: https://phpstan.org/developing-extensions/always-read-written-properties'; + + $this->alwaysWrittenTags = []; + $this->alwaysReadTags = []; + + $this->analyse([__DIR__ . '/data/property-hooks-unused-property.php'], [ + [ + 'Property PropertyHooksUnusedProperty\FooUnused::$a is unused.', + 32, + $tip, + ], + [ + 'Property PropertyHooksUnusedProperty\FooOnlyRead::$a is never written, only read.', + 46, + $tip, + ], + [ + 'Property PropertyHooksUnusedProperty\FooOnlyWritten::$a is never read, only written.', + 65, + $tip, + ], + [ + 'Property PropertyHooksUnusedProperty\ReadInAnotherPropertyHook2::$bar is never written, only read.', + 95, + $tip, + ], + [ + 'Property PropertyHooksUnusedProperty\WrittenInAnotherPropertyHook::$bar is never read, only written.', + 105, + $tip, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/DeadCode/data/property-hooks-unused-property.php b/tests/PHPStan/Rules/DeadCode/data/property-hooks-unused-property.php new file mode 100644 index 0000000000..6b856eb132 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/property-hooks-unused-property.php @@ -0,0 +1,112 @@ += 8.4 + +namespace PropertyHooksUnusedProperty; + +class FooUsed +{ + + private int $a { + get { + return $this->a + 100; + } + set { + $this->a = $value - 100; + } + } + + public function setA(int $a): void + { + $this->a = $a; + } + + public function getA(): int + { + return $this->a; + } + +} + +class FooUnused +{ + + private int $a { + get { + return $this->a + 100; + } + set { + $this->a = $value - 100; + } + } + +} + +class FooOnlyRead +{ + + private int $a { + get { + return $this->a + 100; + } + set { + $this->a = $value - 100; + } + } + + public function getA(): int + { + return $this->a; + } + +} + +class FooOnlyWritten +{ + + private int $a { + get { + return $this->a + 100; + } + set { + $this->a = $value - 100; + } + } + + public function setA(int $a): void + { + $this->a = $a; + } + +} + +class ReadInAnotherPropertyHook +{ + public function __construct( + private readonly string $bar, + ) {} + + public string $virtualProperty { + get => $this->bar; + } +} + +class ReadInAnotherPropertyHook2 +{ + + private string $bar; + + public string $virtualProperty { + get => $this->bar; + } +} + +class WrittenInAnotherPropertyHook +{ + + private string $bar; + + public string $virtualProperty { + set { + $this->bar = 'test'; + } + } +} From 0d22056a45c610c53faa374c6703a0db39fb80ad Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 15 Dec 2024 15:51:05 +0100 Subject: [PATCH 11/41] Fix MissingReturnRule for property hooks --- src/Analyser/NodeScopeResolver.php | 14 ++--- src/Node/PropertyHookStatementNode.php | 52 +++++++++++++++++++ src/Rules/Missing/MissingReturnRule.php | 11 ++-- .../Rules/Missing/MissingReturnRuleTest.php | 19 +++++++ .../data/property-hooks-missing-return.php | 34 ++++++++++++ 5 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 src/Node/PropertyHookStatementNode.php create mode 100644 tests/PHPStan/Rules/Missing/data/property-hooks-missing-return.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 0056af9f3f..7ad28508ac 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -114,6 +114,7 @@ use PHPStan\Node\MethodReturnStatementsNode; use PHPStan\Node\NoopExpressionNode; use PHPStan\Node\PropertyAssignNode; +use PHPStan\Node\PropertyHookStatementNode; use PHPStan\Node\ReturnStatement; use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Node\UnreachableStatementNode; @@ -343,6 +344,7 @@ public function processStmtNodes( $stmtCount = count($stmts); $shouldCheckLastStatement = $parentNode instanceof Node\Stmt\Function_ || $parentNode instanceof Node\Stmt\ClassMethod + || $parentNode instanceof PropertyHookStatementNode || $parentNode instanceof Expr\Closure; foreach ($stmts as $i => $stmt) { if ($alreadyTerminated && !($stmt instanceof Node\Stmt\Function_ || $stmt instanceof Node\Stmt\ClassLike)) { @@ -360,7 +362,7 @@ public function processStmtNodes( $hasYield = $hasYield || $statementResult->hasYield(); if ($shouldCheckLastStatement && $isLast) { - /** @var Node\Stmt\Function_|Node\Stmt\ClassMethod|Expr\Closure $parentNode */ + /** @var Node\Stmt\Function_|Node\Stmt\ClassMethod|PropertyHookStatementNode|Expr\Closure $parentNode */ $parentNode = $parentNode; $endStatements = $statementResult->getEndStatements(); @@ -377,7 +379,7 @@ public function processStmtNodes( $endStatementResult->getThrowPoints(), $endStatementResult->getImpurePoints(), ), - $parentNode->returnType !== null, + $parentNode->getReturnType() !== null, ), $endStatementResult->getScope()); } } else { @@ -391,7 +393,7 @@ public function processStmtNodes( $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), ), - $parentNode->returnType !== null, + $parentNode->getReturnType() !== null, ), $scope); } } @@ -414,9 +416,9 @@ public function processStmtNodes( $statementResult = new StatementResult($scope, $hasYield, $alreadyTerminated, $exitPoints, $throwPoints, $impurePoints); if ($stmtCount === 0 && $shouldCheckLastStatement) { - /** @var Node\Stmt\Function_|Node\Stmt\ClassMethod|Expr\Closure $parentNode */ + /** @var Node\Stmt\Function_|Node\Stmt\ClassMethod|PropertyHookStatementNode|Expr\Closure $parentNode */ $parentNode = $parentNode; - $returnTypeNode = $parentNode->returnType; + $returnTypeNode = $parentNode->getReturnType(); if ($parentNode instanceof Expr\Closure) { $parentNode = new Node\Stmt\Expression($parentNode, $parentNode->getAttributes()); } @@ -4700,7 +4702,7 @@ private function processPropertyHooks( $this->processExprNode($stmt, $hook->body, $hookScope, $nodeCallback, ExpressionContext::createTopLevel()); $nodeCallback(new PropertyAssignNode(new PropertyFetch(new Variable('this'), $propertyName, $hook->body->getAttributes()), $hook->body, false), $hookScope); } elseif (is_array($hook->body)) { - $this->processStmtNodes($stmt, $hook->body, $hookScope, $nodeCallback, StatementContext::createTopLevel()); + $this->processStmtNodes(new PropertyHookStatementNode($hook), $hook->body, $hookScope, $nodeCallback, StatementContext::createTopLevel()); } } diff --git a/src/Node/PropertyHookStatementNode.php b/src/Node/PropertyHookStatementNode.php new file mode 100644 index 0000000000..301d8359e4 --- /dev/null +++ b/src/Node/PropertyHookStatementNode.php @@ -0,0 +1,52 @@ +propertyHook->getAttributes()); + } + + public function getPropertyHook(): PropertyHook + { + return $this->propertyHook; + } + + /** + * @return null + */ + public function getReturnType() + { + return null; + } + + public function getType(): string + { + return 'PHPStan_Node_PropertyHookStatementNode'; + } + + public function getSubNodeNames(): array + { + return []; + } + + +} diff --git a/src/Rules/Missing/MissingReturnRule.php b/src/Rules/Missing/MissingReturnRule.php index 9a3e64e3ac..ef53fa16c8 100644 --- a/src/Rules/Missing/MissingReturnRule.php +++ b/src/Rules/Missing/MissingReturnRule.php @@ -6,7 +6,7 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Node\ExecutionEndNode; -use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\ShouldNotHappenException; @@ -19,6 +19,7 @@ use PHPStan\Type\VerbosityLevel; use PHPStan\Type\VoidType; use function sprintf; +use function ucfirst; /** * @implements Rule @@ -55,8 +56,12 @@ public function processNode(Node $node, Scope $scope): array } } elseif ($scopeFunction !== null) { $returnType = $scopeFunction->getReturnType(); - if ($scopeFunction instanceof MethodReflection) { - $description = sprintf('Method %s::%s()', $scopeFunction->getDeclaringClass()->getDisplayName(), $scopeFunction->getName()); + if ($scopeFunction instanceof PhpMethodFromParserNodeReflection) { + if (!$scopeFunction->isPropertyHook()) { + $description = sprintf('Method %s::%s()', $scopeFunction->getDeclaringClass()->getDisplayName(), $scopeFunction->getName()); + } else { + $description = sprintf('%s hook for property %s::$%s', ucfirst($scopeFunction->getPropertyHookName()), $scopeFunction->getDeclaringClass()->getDisplayName(), $scopeFunction->getHookedPropertyName()); + } } else { $description = sprintf('Function %s()', $scopeFunction->getName()); } diff --git a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php index 79eb736c16..9c170899f2 100644 --- a/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php +++ b/tests/PHPStan/Rules/Missing/MissingReturnRuleTest.php @@ -351,4 +351,23 @@ public function testBug9374(): void $this->analyse([__DIR__ . '/data/bug-9374.php'], []); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->checkExplicitMixedMissingReturn = true; + $this->analyse([__DIR__ . '/data/property-hooks-missing-return.php'], [ + [ + 'Get hook for property PropertyHooksMissingReturn\Foo::$i should return int but return statement is missing.', + 10, + ], + [ + 'Get hook for property PropertyHooksMissingReturn\Foo::$j should return int but return statement is missing.', + 23, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Missing/data/property-hooks-missing-return.php b/tests/PHPStan/Rules/Missing/data/property-hooks-missing-return.php new file mode 100644 index 0000000000..eff880e46c --- /dev/null +++ b/tests/PHPStan/Rules/Missing/data/property-hooks-missing-return.php @@ -0,0 +1,34 @@ += 8.4 + +namespace PropertyHooksMissingReturn; + +class Foo +{ + + public int $i { + get { + if (rand(0, 1)) { + + } else { + return 1; + } + } + + set { + // set hook returns void + } + } + + public int $j { + get { + + } + } + + public int $ok { + get { + return $this->ok + 1; + } + } + +} From 21fbe1e8a970ae1a6934321a3a1810b2afefe9df Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 15 Dec 2024 16:01:05 +0100 Subject: [PATCH 12/41] ExtendedPropertyReflection - methods describing hooked properties --- src/Node/PropertyHookStatementNode.php | 1 - .../AnnotationPropertyReflection.php | 27 ++ .../Dummy/ChangedTypePropertyReflection.php | 26 ++ .../Dummy/DummyPropertyReflection.php | 27 ++ src/Reflection/ExtendedPropertyReflection.php | 22 ++ src/Reflection/Php/EnumPropertyReflection.php | 27 ++ .../Php/PhpClassReflectionExtension.php | 63 +++- src/Reflection/Php/PhpMethodReflection.php | 59 +++ src/Reflection/Php/PhpPropertyReflection.php | 46 +++ .../Php/SimpleXMLElementProperty.php | 27 ++ .../Php/UniversalObjectCrateProperty.php | 27 ++ src/Reflection/ResolvedPropertyReflection.php | 29 ++ .../IntersectionTypePropertyReflection.php | 69 +++- .../Type/UnionTypePropertyReflection.php | 69 +++- .../WrappedExtendedPropertyReflection.php | 26 ++ .../Properties/FoundPropertyReflection.php | 26 ++ .../ShortGetPropertyHookReturnTypeRule.php | 1 + src/Type/ObjectShapePropertyReflection.php | 27 ++ .../PHPStan/Analyser/nsrt/property-hooks.php | 27 ++ .../Reflection/ClassReflectionTest.php | 340 ++++++++++++++++++ ...ShortGetPropertyHookReturnTypeRuleTest.php | 3 +- 21 files changed, 940 insertions(+), 29 deletions(-) diff --git a/src/Node/PropertyHookStatementNode.php b/src/Node/PropertyHookStatementNode.php index 301d8359e4..34bdbcfd31 100644 --- a/src/Node/PropertyHookStatementNode.php +++ b/src/Node/PropertyHookStatementNode.php @@ -48,5 +48,4 @@ public function getSubNodeNames(): array return []; } - } diff --git a/src/Reflection/Annotations/AnnotationPropertyReflection.php b/src/Reflection/Annotations/AnnotationPropertyReflection.php index 78b822ca3a..e6747a153c 100644 --- a/src/Reflection/Annotations/AnnotationPropertyReflection.php +++ b/src/Reflection/Annotations/AnnotationPropertyReflection.php @@ -3,7 +3,9 @@ namespace PHPStan\Reflection\Annotations; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; @@ -85,4 +87,29 @@ public function getDocComment(): ?string return null; } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + } diff --git a/src/Reflection/Dummy/ChangedTypePropertyReflection.php b/src/Reflection/Dummy/ChangedTypePropertyReflection.php index c4715616e7..cc431c7a5b 100644 --- a/src/Reflection/Dummy/ChangedTypePropertyReflection.php +++ b/src/Reflection/Dummy/ChangedTypePropertyReflection.php @@ -3,6 +3,7 @@ namespace PHPStan\Reflection\Dummy; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\WrapperPropertyReflection; use PHPStan\TrinaryLogic; @@ -85,4 +86,29 @@ public function getOriginalReflection(): ExtendedPropertyReflection return $this->reflection; } + public function isAbstract(): TrinaryLogic + { + return $this->reflection->isAbstract(); + } + + public function isFinal(): TrinaryLogic + { + return $this->reflection->isFinal(); + } + + public function isVirtual(): TrinaryLogic + { + return $this->reflection->isVirtual(); + } + + public function hasHook(string $hookType): bool + { + return $this->reflection->hasHook($hookType); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + return $this->reflection->getHook($hookType); + } + } diff --git a/src/Reflection/Dummy/DummyPropertyReflection.php b/src/Reflection/Dummy/DummyPropertyReflection.php index 55addb6d90..a98855249a 100644 --- a/src/Reflection/Dummy/DummyPropertyReflection.php +++ b/src/Reflection/Dummy/DummyPropertyReflection.php @@ -3,8 +3,10 @@ namespace PHPStan\Reflection\Dummy; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\ReflectionProviderStaticAccessor; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -80,4 +82,29 @@ public function getDocComment(): ?string return null; } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + } diff --git a/src/Reflection/ExtendedPropertyReflection.php b/src/Reflection/ExtendedPropertyReflection.php index fbeac4f3a7..85b16a86d6 100644 --- a/src/Reflection/ExtendedPropertyReflection.php +++ b/src/Reflection/ExtendedPropertyReflection.php @@ -2,6 +2,8 @@ namespace PHPStan\Reflection; +use PHPStan\TrinaryLogic; + /** * The purpose of this interface is to be able to * answer more questions about properties @@ -19,4 +21,24 @@ interface ExtendedPropertyReflection extends PropertyReflection { + public const HOOK_GET = 'get'; + + public const HOOK_SET = 'set'; + + public function isAbstract(): TrinaryLogic; + + public function isFinal(): TrinaryLogic; + + public function isVirtual(): TrinaryLogic; + + /** + * @param self::HOOK_* $hookType + */ + public function hasHook(string $hookType): bool; + + /** + * @param self::HOOK_* $hookType + */ + public function getHook(string $hookType): ExtendedMethodReflection; + } diff --git a/src/Reflection/Php/EnumPropertyReflection.php b/src/Reflection/Php/EnumPropertyReflection.php index ef9ea9a2be..c9540c7b64 100644 --- a/src/Reflection/Php/EnumPropertyReflection.php +++ b/src/Reflection/Php/EnumPropertyReflection.php @@ -3,7 +3,9 @@ namespace PHPStan\Reflection\Php; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; @@ -79,4 +81,29 @@ public function isInternal(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + } diff --git a/src/Reflection/Php/PhpClassReflectionExtension.php b/src/Reflection/Php/PhpClassReflectionExtension.php index c42863737c..333f88bd33 100644 --- a/src/Reflection/Php/PhpClassReflectionExtension.php +++ b/src/Reflection/Php/PhpClassReflectionExtension.php @@ -42,6 +42,7 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\GeneralizePrecision; +use PHPStan\Type\Generic\TemplateMixedType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVariance; @@ -212,7 +213,7 @@ private function createProperty( $types[] = $value; } - return new PhpPropertyReflection($declaringClassReflection, null, null, TypeCombinator::union(...$types), $classReflection->getNativeReflection()->getProperty($propertyName), null, false, false, false, false); + return new PhpPropertyReflection($declaringClassReflection, null, null, TypeCombinator::union(...$types), $classReflection->getNativeReflection()->getProperty($propertyName), null, null, null, false, false, false, false); } } @@ -353,12 +354,72 @@ private function createProperty( $declaringTrait = $reflectionProvider->getClass($declaringTraitName); } + $getHook = null; + $setHook = null; + + $betterReflection = $propertyReflection->getBetterReflection(); + if ($betterReflection->hasHook('get')) { + $betterReflectionGetHook = $betterReflection->getHook('get'); + if ($betterReflectionGetHook === null) { + throw new ShouldNotHappenException(); + } + $getHook = $this->createUserlandMethodReflection( + $declaringClassReflection, + $declaringClassReflection, + new ReflectionMethod($betterReflectionGetHook), + $declaringTraitName, + ); + + if ($phpDocType !== null) { + $getHookMethodReflectionVariant = $getHook->getOnlyVariant(); + $getHookMethodReflectionVariantPhpDocReturnType = $getHookMethodReflectionVariant->getPhpDocReturnType(); + if ( + $getHookMethodReflectionVariantPhpDocReturnType instanceof MixedType + && !$getHookMethodReflectionVariantPhpDocReturnType instanceof TemplateMixedType + && !$getHookMethodReflectionVariantPhpDocReturnType->isExplicitMixed() + ) { + $getHook = $getHook->changePropertyGetHookPhpDocType($phpDocType); + } + } + } + + if ($betterReflection->hasHook('set')) { + $betterReflectionSetHook = $betterReflection->getHook('set'); + if ($betterReflectionSetHook === null) { + throw new ShouldNotHappenException(); + } + $setHook = $this->createUserlandMethodReflection( + $declaringClassReflection, + $declaringClassReflection, + new ReflectionMethod($betterReflectionSetHook), + $declaringTraitName, + ); + + if ($phpDocType !== null) { + $setHookMethodReflectionVariant = $setHook->getOnlyVariant(); + $setHookMethodReflectionParameters = $setHookMethodReflectionVariant->getParameters(); + if (isset($setHookMethodReflectionParameters[0])) { + $setHookMethodReflectionParameter = $setHookMethodReflectionParameters[0]; + $setHookMethodReflectionParameterPhpDocType = $setHookMethodReflectionParameter->getPhpDocType(); + if ( + $setHookMethodReflectionParameterPhpDocType instanceof MixedType + && !$setHookMethodReflectionParameterPhpDocType instanceof TemplateMixedType + && !$setHookMethodReflectionParameterPhpDocType->isExplicitMixed() + ) { + $setHook = $setHook->changePropertySetHookPhpDocType($setHookMethodReflectionParameter->getName(), $phpDocType); + } + } + } + } + return new PhpPropertyReflection( $declaringClassReflection, $declaringTrait, $nativeType, $phpDocType, $propertyReflection, + $getHook, + $setHook, $deprecatedDescription, $isDeprecated, $isInternal, diff --git a/src/Reflection/Php/PhpMethodReflection.php b/src/Reflection/Php/PhpMethodReflection.php index 888530f270..6209a57bc8 100644 --- a/src/Reflection/Php/PhpMethodReflection.php +++ b/src/Reflection/Php/PhpMethodReflection.php @@ -450,4 +450,63 @@ public function isPure(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->isPure); } + public function changePropertyGetHookPhpDocType(Type $phpDocType): self + { + return new self( + $this->initializerExprTypeResolver, + $this->declaringClass, + $this->declaringTrait, + $this->reflection, + $this->reflectionProvider, + $this->parser, + $this->templateTypeMap, + $this->phpDocParameterTypes, + $phpDocType, + $this->phpDocThrowType, + $this->deprecatedDescription, + $this->isDeprecated, + $this->isInternal, + $this->isFinal, + $this->isPure, + $this->asserts, + $this->acceptsNamedArguments, + $this->selfOutType, + $this->phpDocComment, + $this->phpDocParameterOutTypes, + $this->immediatelyInvokedCallableParameters, + $this->phpDocClosureThisTypeParameters, + ); + } + + public function changePropertySetHookPhpDocType(string $parameterName, Type $phpDocType): self + { + $phpDocParameterTypes = $this->phpDocParameterTypes; + $phpDocParameterTypes[$parameterName] = $phpDocType; + + return new self( + $this->initializerExprTypeResolver, + $this->declaringClass, + $this->declaringTrait, + $this->reflection, + $this->reflectionProvider, + $this->parser, + $this->templateTypeMap, + $phpDocParameterTypes, + $this->phpDocReturnType, + $this->phpDocThrowType, + $this->deprecatedDescription, + $this->isDeprecated, + $this->isInternal, + $this->isFinal, + $this->isPure, + $this->asserts, + $this->acceptsNamedArguments, + $this->selfOutType, + $this->phpDocComment, + $this->phpDocParameterOutTypes, + $this->immediatelyInvokedCallableParameters, + $this->phpDocClosureThisTypeParameters, + ); + } + } diff --git a/src/Reflection/Php/PhpPropertyReflection.php b/src/Reflection/Php/PhpPropertyReflection.php index 28e559719a..3926a39789 100644 --- a/src/Reflection/Php/PhpPropertyReflection.php +++ b/src/Reflection/Php/PhpPropertyReflection.php @@ -7,11 +7,14 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionProperty; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionUnionType; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\Reflection\MissingMethodFromReflectionException; use PHPStan\TrinaryLogic; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\TypehintHelper; +use function sprintf; /** * @api @@ -29,6 +32,8 @@ public function __construct( private ReflectionUnionType|ReflectionNamedType|ReflectionIntersectionType|null $nativeType, private ?Type $phpDocType, private ReflectionProperty $reflection, + private ?ExtendedMethodReflection $getHook, + private ?ExtendedMethodReflection $setHook, private ?string $deprecatedDescription, private bool $isDeprecated, private bool $isInternal, @@ -182,4 +187,45 @@ public function getNativeReflection(): ReflectionProperty return $this->reflection; } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isAbstract()); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isFinal()); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createFromBoolean($this->reflection->isVirtual()); + } + + public function hasHook(string $hookType): bool + { + if ($hookType === 'get') { + return $this->getHook !== null; + } + + return $this->setHook !== null; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + if ($hookType === 'get') { + if ($this->getHook === null) { + throw new MissingMethodFromReflectionException($this->declaringClass->getName(), sprintf('$%s::get', $this->reflection->getName())); + } + + return $this->getHook; + } + + if ($this->setHook === null) { + throw new MissingMethodFromReflectionException($this->declaringClass->getName(), sprintf('$%s::set', $this->reflection->getName())); + } + + return $this->setHook; + } + } diff --git a/src/Reflection/Php/SimpleXMLElementProperty.php b/src/Reflection/Php/SimpleXMLElementProperty.php index 4073809a23..a06da4df47 100644 --- a/src/Reflection/Php/SimpleXMLElementProperty.php +++ b/src/Reflection/Php/SimpleXMLElementProperty.php @@ -3,7 +3,9 @@ namespace PHPStan\Reflection\Php; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\BooleanType; use PHPStan\Type\FloatType; @@ -93,4 +95,29 @@ public function getDocComment(): ?string return null; } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + } diff --git a/src/Reflection/Php/UniversalObjectCrateProperty.php b/src/Reflection/Php/UniversalObjectCrateProperty.php index ae1e86fe8c..6013bcfa3b 100644 --- a/src/Reflection/Php/UniversalObjectCrateProperty.php +++ b/src/Reflection/Php/UniversalObjectCrateProperty.php @@ -3,7 +3,9 @@ namespace PHPStan\Reflection\Php; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; @@ -83,4 +85,29 @@ public function getDocComment(): ?string return null; } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + } diff --git a/src/Reflection/ResolvedPropertyReflection.php b/src/Reflection/ResolvedPropertyReflection.php index 8e8447ecac..43435261f6 100644 --- a/src/Reflection/ResolvedPropertyReflection.php +++ b/src/Reflection/ResolvedPropertyReflection.php @@ -144,4 +144,33 @@ public function isInternal(): TrinaryLogic return $this->reflection->isInternal(); } + public function isAbstract(): TrinaryLogic + { + return $this->reflection->isAbstract(); + } + + public function isFinal(): TrinaryLogic + { + return $this->reflection->isFinal(); + } + + public function isVirtual(): TrinaryLogic + { + return $this->reflection->isVirtual(); + } + + public function hasHook(string $hookType): bool + { + return $this->reflection->hasHook($hookType); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + return new ResolvedMethodReflection( + $this->reflection->getHook($hookType), + $this->templateTypeMap, + $this->callSiteVarianceMap, + ); + } + } diff --git a/src/Reflection/Type/IntersectionTypePropertyReflection.php b/src/Reflection/Type/IntersectionTypePropertyReflection.php index 0db311786e..40988deb29 100644 --- a/src/Reflection/Type/IntersectionTypePropertyReflection.php +++ b/src/Reflection/Type/IntersectionTypePropertyReflection.php @@ -3,8 +3,9 @@ namespace PHPStan\Reflection\Type; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; -use PHPStan\Reflection\PropertyReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -16,7 +17,7 @@ final class IntersectionTypePropertyReflection implements ExtendedPropertyReflec { /** - * @param PropertyReflection[] $properties + * @param ExtendedPropertyReflection[] $properties */ public function __construct(private array $properties) { @@ -29,22 +30,22 @@ public function getDeclaringClass(): ClassReflection public function isStatic(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isStatic()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isStatic()); } public function isPrivate(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isPrivate()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPrivate()); } public function isPublic(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isPublic()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPublic()); } public function isDeprecated(): TrinaryLogic { - return TrinaryLogic::lazyMaxMin($this->properties, static fn (PropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isDeprecated()); + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isDeprecated()); } public function getDeprecatedDescription(): ?string @@ -71,7 +72,7 @@ public function getDeprecatedDescription(): ?string public function isInternal(): TrinaryLogic { - return TrinaryLogic::lazyMaxMin($this->properties, static fn (PropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isInternal()); + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isInternal()); } public function getDocComment(): ?string @@ -81,31 +82,31 @@ public function getDocComment(): ?string public function getReadableType(): Type { - return TypeCombinator::intersect(...array_map(static fn (PropertyReflection $property): Type => $property->getReadableType(), $this->properties)); + return TypeCombinator::intersect(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getReadableType(), $this->properties)); } public function getWritableType(): Type { - return TypeCombinator::intersect(...array_map(static fn (PropertyReflection $property): Type => $property->getWritableType(), $this->properties)); + return TypeCombinator::intersect(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getWritableType(), $this->properties)); } public function canChangeTypeAfterAssignment(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->canChangeTypeAfterAssignment()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->canChangeTypeAfterAssignment()); } public function isReadable(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isReadable()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isReadable()); } public function isWritable(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isWritable()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isWritable()); } /** - * @param callable(PropertyReflection): bool $cb + * @param callable(ExtendedPropertyReflection): bool $cb */ private function computeResult(callable $cb): bool { @@ -117,4 +118,46 @@ private function computeResult(callable $cb): bool return $result; } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isAbstract()); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isFinal()); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::lazyMaxMin($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isVirtual()); + } + + public function hasHook(string $hookType): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->hasHook($hookType)); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + $hooks = []; + foreach ($this->properties as $property) { + if (!$property->hasHook($hookType)) { + continue; + } + + $hooks[] = $property->getHook($hookType); + } + + if (count($hooks) === 0) { + throw new ShouldNotHappenException(); + } + + if (count($hooks) === 1) { + return $hooks[0]; + } + + return new IntersectionTypeMethodReflection($hooks[0]->getName(), $hooks); + } + } diff --git a/src/Reflection/Type/UnionTypePropertyReflection.php b/src/Reflection/Type/UnionTypePropertyReflection.php index 91964e8eda..0f1c6c5162 100644 --- a/src/Reflection/Type/UnionTypePropertyReflection.php +++ b/src/Reflection/Type/UnionTypePropertyReflection.php @@ -3,8 +3,9 @@ namespace PHPStan\Reflection\Type; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; -use PHPStan\Reflection\PropertyReflection; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -16,7 +17,7 @@ final class UnionTypePropertyReflection implements ExtendedPropertyReflection { /** - * @param PropertyReflection[] $properties + * @param ExtendedPropertyReflection[] $properties */ public function __construct(private array $properties) { @@ -29,22 +30,22 @@ public function getDeclaringClass(): ClassReflection public function isStatic(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isStatic()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isStatic()); } public function isPrivate(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isPrivate()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPrivate()); } public function isPublic(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isPublic()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isPublic()); } public function isDeprecated(): TrinaryLogic { - return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (PropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isDeprecated()); + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isDeprecated()); } public function getDeprecatedDescription(): ?string @@ -71,7 +72,7 @@ public function getDeprecatedDescription(): ?string public function isInternal(): TrinaryLogic { - return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (PropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isInternal()); + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isInternal()); } public function getDocComment(): ?string @@ -81,31 +82,31 @@ public function getDocComment(): ?string public function getReadableType(): Type { - return TypeCombinator::union(...array_map(static fn (PropertyReflection $property): Type => $property->getReadableType(), $this->properties)); + return TypeCombinator::union(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getReadableType(), $this->properties)); } public function getWritableType(): Type { - return TypeCombinator::union(...array_map(static fn (PropertyReflection $property): Type => $property->getWritableType(), $this->properties)); + return TypeCombinator::union(...array_map(static fn (ExtendedPropertyReflection $property): Type => $property->getWritableType(), $this->properties)); } public function canChangeTypeAfterAssignment(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->canChangeTypeAfterAssignment()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->canChangeTypeAfterAssignment()); } public function isReadable(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isReadable()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isReadable()); } public function isWritable(): bool { - return $this->computeResult(static fn (PropertyReflection $property) => $property->isWritable()); + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->isWritable()); } /** - * @param callable(PropertyReflection): bool $cb + * @param callable(ExtendedPropertyReflection): bool $cb */ private function computeResult(callable $cb): bool { @@ -117,4 +118,46 @@ private function computeResult(callable $cb): bool return $result; } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isAbstract()); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isFinal()); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::lazyExtremeIdentity($this->properties, static fn (ExtendedPropertyReflection $propertyReflection): TrinaryLogic => $propertyReflection->isVirtual()); + } + + public function hasHook(string $hookType): bool + { + return $this->computeResult(static fn (ExtendedPropertyReflection $property) => $property->hasHook($hookType)); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + $hooks = []; + foreach ($this->properties as $property) { + if (!$property->hasHook($hookType)) { + continue; + } + + $hooks[] = $property->getHook($hookType); + } + + if (count($hooks) === 0) { + throw new ShouldNotHappenException(); + } + + if (count($hooks) === 1) { + return $hooks[0]; + } + + return new UnionTypeMethodReflection($hooks[0]->getName(), $hooks); + } + } diff --git a/src/Reflection/WrappedExtendedPropertyReflection.php b/src/Reflection/WrappedExtendedPropertyReflection.php index 9b941088bc..1469cd3f43 100644 --- a/src/Reflection/WrappedExtendedPropertyReflection.php +++ b/src/Reflection/WrappedExtendedPropertyReflection.php @@ -2,6 +2,7 @@ namespace PHPStan\Reflection; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Type; @@ -77,4 +78,29 @@ public function isInternal(): TrinaryLogic return $this->property->isInternal(); } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + } diff --git a/src/Rules/Properties/FoundPropertyReflection.php b/src/Rules/Properties/FoundPropertyReflection.php index a05182fa66..b74c62975a 100644 --- a/src/Rules/Properties/FoundPropertyReflection.php +++ b/src/Rules/Properties/FoundPropertyReflection.php @@ -4,6 +4,7 @@ use PHPStan\Analyser\Scope; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\Php\PhpPropertyReflection; use PHPStan\Reflection\WrapperPropertyReflection; @@ -127,4 +128,29 @@ public function getNativeReflection(): ?PhpPropertyReflection return $reflection; } + public function isAbstract(): TrinaryLogic + { + return $this->originalPropertyReflection->isAbstract(); + } + + public function isFinal(): TrinaryLogic + { + return $this->originalPropertyReflection->isFinal(); + } + + public function isVirtual(): TrinaryLogic + { + return $this->originalPropertyReflection->isVirtual(); + } + + public function hasHook(string $hookType): bool + { + return $this->originalPropertyReflection->hasHook($hookType); + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + return $this->originalPropertyReflection->getHook($hookType); + } + } diff --git a/src/Rules/Properties/ShortGetPropertyHookReturnTypeRule.php b/src/Rules/Properties/ShortGetPropertyHookReturnTypeRule.php index 1651ddfdcd..cf30777b19 100644 --- a/src/Rules/Properties/ShortGetPropertyHookReturnTypeRule.php +++ b/src/Rules/Properties/ShortGetPropertyHookReturnTypeRule.php @@ -7,6 +7,7 @@ use PHPStan\Node\InPropertyHookNode; use PHPStan\Rules\FunctionReturnTypeCheck; use PHPStan\Rules\Rule; +use function sprintf; /** * @implements Rule diff --git a/src/Type/ObjectShapePropertyReflection.php b/src/Type/ObjectShapePropertyReflection.php index ca96ebae94..7c05525aae 100644 --- a/src/Type/ObjectShapePropertyReflection.php +++ b/src/Type/ObjectShapePropertyReflection.php @@ -3,8 +3,10 @@ namespace PHPStan\Type; use PHPStan\Reflection\ClassReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedPropertyReflection; use PHPStan\Reflection\ReflectionProviderStaticAccessor; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use stdClass; @@ -82,4 +84,29 @@ public function isInternal(): TrinaryLogic return TrinaryLogic::createNo(); } + public function isAbstract(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isFinal(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function isVirtual(): TrinaryLogic + { + return TrinaryLogic::createNo(); + } + + public function hasHook(string $hookType): bool + { + return false; + } + + public function getHook(string $hookType): ExtendedMethodReflection + { + throw new ShouldNotHappenException(); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/property-hooks.php b/tests/PHPStan/Analyser/nsrt/property-hooks.php index 0e49e0e981..cc143f855d 100644 --- a/tests/PHPStan/Analyser/nsrt/property-hooks.php +++ b/tests/PHPStan/Analyser/nsrt/property-hooks.php @@ -13,6 +13,9 @@ class Foo set { assertType('int', $value); } + get { + return 1; + } } public int $j { @@ -32,6 +35,9 @@ class Foo set { assertType('array', $value); } + get { + return []; + } } /** @var array */ @@ -106,6 +112,9 @@ public function __construct( set { assertType('array', $value); } + get { + return []; + } }, /** @var array */ public array $m { @@ -137,6 +146,9 @@ public function __construct( set { assertType('array', $value); } + get { + return []; + } }, public array $m { set (array $val) { @@ -160,6 +172,9 @@ class FooGenerics set (array $val) { assertType('array', $val); } + get { + + } } public int $n { @@ -167,6 +182,9 @@ class FooGenerics set (int|array $val) { assertType('array|int', $val); } + get { + + } } } @@ -183,18 +201,27 @@ public function __construct( set { assertType('array', $value); } + get { + + } }, /** @var array */ public array $m { set (array $val) { assertType('array', $val); } + get { + + } }, public int $n { /** @param int|array $val */ set (int|array $val) { assertType('array|int', $val); } + get { + + } }, ) { diff --git a/tests/PHPStan/Reflection/ClassReflectionTest.php b/tests/PHPStan/Reflection/ClassReflectionTest.php index d798e65b66..7058b8b510 100644 --- a/tests/PHPStan/Reflection/ClassReflectionTest.php +++ b/tests/PHPStan/Reflection/ClassReflectionTest.php @@ -29,12 +29,15 @@ use NestedTraits\NoTrait; use PHPStan\Testing\PHPStanTestCase; use PHPStan\Testing\RuleTestCase; +use PHPStan\Type\Generic\GenericObjectType; use PHPStan\Type\IntegerType; +use PHPStan\Type\VerbosityLevel; use PHPUnit\Framework\TestCase; use ReflectionClass; use WrongClassConstantFile\SecuredRouter; use function array_map; use function array_values; +use function count; use const PHP_VERSION_ID; class ClassReflectionTest extends PHPStanTestCase @@ -322,4 +325,341 @@ public function testIs(): void $this->assertFalse($classReflection->is(RuleTestCase::class)); } + public function dataPropertyHooks(): iterable + { + if (PHP_VERSION_ID < 80400) { + return; + } + + $reflectionProvider = $this->createReflectionProvider(); + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\Foo'), + 'i', + 'set', + ['int'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\Foo'), + 'i', + 'get', + [], + 'int', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\Foo'), + 'l', + 'get', + [], + 'array', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\Foo'), + 'n', + 'set', + ['array|int'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooShort'), + 'i', + 'set', + ['int'], + 'void', + false, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooShort'), + 'k', + 'set', + ['int|string'], + 'void', + false, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooShort'), + 'l', + 'set', + ['array'], + 'void', + false, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooShort'), + 'm', + 'set', + ['array'], + 'void', + false, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooShort'), + 'n', + 'set', + ['array|int'], + 'void', + false, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructor'), + 'i', + 'set', + ['int'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructor'), + 'j', + 'set', + ['int'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructor'), + 'k', + 'set', + ['int|string'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructor'), + 'l', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructor'), + 'l', + 'get', + [], + 'array', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructor'), + 'm', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructor'), + 'n', + 'set', + ['array|int'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructorWithParam'), + 'l', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructorWithParam'), + 'l', + 'get', + [], + 'array', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooConstructorWithParam'), + 'm', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenerics'), + 'm', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenerics'), + 'n', + 'set', + ['array|int'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenerics'), + 'm', + 'get', + [], + 'array', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenerics'), + 'n', + 'get', + [], + 'int', + true, + ]; + + $specificFooGenerics = (new GenericObjectType('PropertyHooksTypes\\FooGenerics', [new IntegerType()]))->getClassReflection(); + + yield [ + $specificFooGenerics, + 'n', + 'set', + ['array|int'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenerics'), + 'n', + 'get', + [], + 'int', + true, + ]; + + yield [ + $specificFooGenerics, + 'm', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenerics'), + 'm', + 'get', + [], + 'array', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenericsConstructor'), + 'l', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenericsConstructor'), + 'm', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $reflectionProvider->getClass('PropertyHooksTypes\\FooGenericsConstructor'), + 'n', + 'set', + ['array|int'], + 'void', + true, + ]; + + $specificFooGenericsConstructor = (new GenericObjectType('PropertyHooksTypes\\FooGenericsConstructor', [new IntegerType()]))->getClassReflection(); + + yield [ + $specificFooGenericsConstructor, + 'n', + 'set', + ['array|int'], + 'void', + true, + ]; + + yield [ + $specificFooGenericsConstructor, + 'm', + 'set', + ['array'], + 'void', + true, + ]; + + yield [ + $specificFooGenericsConstructor, + 'm', + 'get', + [], + 'array', + true, + ]; + } + + /** + * @dataProvider dataPropertyHooks + * @param ExtendedPropertyReflection::HOOK_* $hookName + * @param string[] $parameterTypes + */ + public function testPropertyHooks( + ClassReflection $classReflection, + string $propertyName, + string $hookName, + array $parameterTypes, + string $returnType, + bool $isVirtual, + ): void + { + $propertyReflection = $classReflection->getNativeProperty($propertyName); + $this->assertSame($isVirtual, $propertyReflection->isVirtual()->yes()); + + $hookReflection = $propertyReflection->getHook($hookName); + $hookVariant = $hookReflection->getOnlyVariant(); + $this->assertSame($returnType, $hookVariant->getReturnType()->describe(VerbosityLevel::precise())); + $this->assertCount(count($parameterTypes), $hookVariant->getParameters()); + + foreach ($hookVariant->getParameters() as $i => $parameter) { + $this->assertSame($parameterTypes[$i], $parameter->getType()->describe(VerbosityLevel::precise())); + } + } + } diff --git a/tests/PHPStan/Rules/Properties/ShortGetPropertyHookReturnTypeRuleTest.php b/tests/PHPStan/Rules/Properties/ShortGetPropertyHookReturnTypeRuleTest.php index 0f4190b433..8a318db7ed 100644 --- a/tests/PHPStan/Rules/Properties/ShortGetPropertyHookReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Properties/ShortGetPropertyHookReturnTypeRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -16,7 +17,7 @@ final class ShortGetPropertyHookReturnTypeRuleTest extends RuleTestCase protected function getRule(): Rule { return new ShortGetPropertyHookReturnTypeRule( - new FunctionReturnTypeCheck(new RuleLevelHelper($this->createReflectionProvider(), true, false, true, true, false, false)) + new FunctionReturnTypeCheck(new RuleLevelHelper($this->createReflectionProvider(), true, false, true, true, false, false)), ); } From bdf631607ddc74a32672774932f87820eeb3ec41 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Dec 2024 15:04:21 +0100 Subject: [PATCH 13/41] Allow wider set hook parameter type when assigning properties outside of their hooks --- src/Reflection/Php/PhpPropertyReflection.php | 8 +++++ .../TypesAssignedToPropertiesRule.php | 26 ++++++++++++++-- .../TypesAssignedToPropertiesRuleTest.php | 22 ++++++++++++++ .../data/assign-hooked-properties.php | 30 +++++++++++++++++++ 4 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Rules/Properties/data/assign-hooked-properties.php diff --git a/src/Reflection/Php/PhpPropertyReflection.php b/src/Reflection/Php/PhpPropertyReflection.php index 3926a39789..babe91bb3b 100644 --- a/src/Reflection/Php/PhpPropertyReflection.php +++ b/src/Reflection/Php/PhpPropertyReflection.php @@ -103,6 +103,14 @@ public function getReadableType(): Type public function getWritableType(): Type { + if ($this->hasHook('set')) { + $setHookVariant = $this->getHook('set')->getOnlyVariant(); + $parameters = $setHookVariant->getParameters(); + if (isset($parameters[0])) { + return $parameters[0]->getType(); + } + } + return $this->getReadableType(); } diff --git a/src/Rules/Properties/TypesAssignedToPropertiesRule.php b/src/Rules/Properties/TypesAssignedToPropertiesRule.php index f043cf3c3e..50d1502401 100644 --- a/src/Rules/Properties/TypesAssignedToPropertiesRule.php +++ b/src/Rules/Properties/TypesAssignedToPropertiesRule.php @@ -3,8 +3,11 @@ namespace PHPStan\Rules\Properties; use PhpParser\Node; +use PhpParser\Node\Expr\PropertyFetch; +use PhpParser\Node\Expr\StaticPropertyFetch; use PHPStan\Analyser\Scope; use PHPStan\Node\PropertyAssignNode; +use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Reflection\PropertyReflection; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; @@ -12,6 +15,7 @@ use PHPStan\Rules\RuleLevelHelper; use PHPStan\Type\VerbosityLevel; use function array_merge; +use function is_string; use function sprintf; /** @@ -34,12 +38,14 @@ public function getNodeType(): string public function processNode(Node $node, Scope $scope): array { - $propertyReflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($node->getPropertyFetch(), $scope); + $propertyFetch = $node->getPropertyFetch(); + $propertyReflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); $errors = []; foreach ($propertyReflections as $propertyReflection) { $errors = array_merge($errors, $this->processSingleProperty( $propertyReflection, + $propertyFetch, $node->getAssignedExpr(), )); } @@ -52,6 +58,7 @@ public function processNode(Node $node, Scope $scope): array */ private function processSingleProperty( FoundPropertyReflection $propertyReflection, + PropertyFetch|StaticPropertyFetch $fetch, Node\Expr $assignedExpr, ): array { @@ -59,8 +66,23 @@ private function processSingleProperty( return []; } - $propertyType = $propertyReflection->getWritableType(); $scope = $propertyReflection->getScope(); + $inFunction = $scope->getFunction(); + if ( + $fetch instanceof PropertyFetch + && $fetch->var instanceof Node\Expr\Variable + && is_string($fetch->var->name) + && $fetch->var->name === 'this' + && $fetch->name instanceof Node\Identifier + && $inFunction instanceof PhpMethodFromParserNodeReflection + && $inFunction->isPropertyHook() + && $inFunction->getHookedPropertyName() === $fetch->name->toString() + ) { + $propertyType = $propertyReflection->getReadableType(); + } else { + $propertyType = $propertyReflection->getWritableType(); + } + $assignedValueType = $scope->getType($assignedExpr); $accepts = $this->ruleLevelHelper->accepts($propertyType, $assignedValueType, $scope->isDeclareStrictTypes()); diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index 061a8af510..dd5c31608d 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -722,4 +722,26 @@ public function testShortBodySetHook(): void ]); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/assign-hooked-properties.php'], [ + [ + 'Property AssignHookedProperties\Foo::$i (int) does not accept array|int.', + 11, + ], + [ + 'Property AssignHookedProperties\Foo::$j (int) does not accept array|int.', + 19, + ], + [ + 'Property AssignHookedProperties\Foo::$i (array|int) does not accept array.', + 27, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/assign-hooked-properties.php b/tests/PHPStan/Rules/Properties/data/assign-hooked-properties.php new file mode 100644 index 0000000000..d25a9f8b4b --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/assign-hooked-properties.php @@ -0,0 +1,30 @@ += 8.4 + +namespace AssignHookedProperties; + +class Foo +{ + + public int $i { + /** @param array|int $val */ + set (array|int $val) { + $this->i = $val; // only int allowed + } + } + + public int $j { + /** @param array|int $val */ + set (array|int $val) { + $this->i = $val; // this is okay - hook called + $this->j = $val; // only int allowed + } + } + + public function doFoo(): void + { + $this->i = ['foo']; // okay + $this->i = 1; // okay + $this->i = [1]; // not okay + } + +} From 373000a8fe619a2f45f41d31e569a86245da3a2d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Dec 2024 15:18:15 +0100 Subject: [PATCH 14/41] Fix canChangeTypeAfterAssignment --- src/Reflection/Php/PhpPropertyReflection.php | 16 +++++++ .../PHPStan/Analyser/nsrt/property-hooks.php | 46 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/Reflection/Php/PhpPropertyReflection.php b/src/Reflection/Php/PhpPropertyReflection.php index babe91bb3b..2879f2a9ea 100644 --- a/src/Reflection/Php/PhpPropertyReflection.php +++ b/src/Reflection/Php/PhpPropertyReflection.php @@ -116,6 +116,22 @@ public function getWritableType(): Type public function canChangeTypeAfterAssignment(): bool { + if ($this->isStatic()) { + return true; + } + + if ($this->isVirtual()->yes()) { + return false; + } + + if ($this->hasHook('get')) { + return false; + } + + if ($this->hasHook('set')) { + return false; + } + return true; } diff --git a/tests/PHPStan/Analyser/nsrt/property-hooks.php b/tests/PHPStan/Analyser/nsrt/property-hooks.php index cc143f855d..4a7f0d9f3a 100644 --- a/tests/PHPStan/Analyser/nsrt/property-hooks.php +++ b/tests/PHPStan/Analyser/nsrt/property-hooks.php @@ -311,3 +311,49 @@ public function __construct( } } + +class CanChangeTypeAfterAssignment +{ + + public int $i; + + public function doFoo(): void + { + assertType('int', $this->i); + $this->i = 1; + assertType('1', $this->i); + } + + public int $virtual { + get { + return 1; + } + set { + $this->i = 1; + } + } + + public function doFoo2(): void + { + assertType('int', $this->virtual); + $this->virtual = 1; + assertType('int', $this->virtual); + } + + public int $backedWithHook { + get { + return $this->backedWithHook + 100; + } + set { + $this->backedWithHook = $this->backedWithHook - 200; + } + } + + public function doFoo3(): void + { + assertType('int', $this->backedWithHook); + $this->backedWithHook = 1; + assertType('int', $this->backedWithHook); + } + +} From 294ba3e0fad06c45c97e5b5711e20701d86254ca Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Dec 2024 15:58:18 +0100 Subject: [PATCH 15/41] Test WritingToReadOnlyPropertiesRule for hooked properties --- src/Reflection/Php/PhpPropertyReflection.php | 10 +++- .../WritingToReadOnlyPropertiesRuleTest.php | 20 ++++++++ ...writing-to-read-only-hooked-properties.php | 49 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Properties/data/writing-to-read-only-hooked-properties.php diff --git a/src/Reflection/Php/PhpPropertyReflection.php b/src/Reflection/Php/PhpPropertyReflection.php index 2879f2a9ea..d8ce00cdf6 100644 --- a/src/Reflection/Php/PhpPropertyReflection.php +++ b/src/Reflection/Php/PhpPropertyReflection.php @@ -179,7 +179,15 @@ public function isReadable(): bool public function isWritable(): bool { - return true; + if ($this->isStatic()) { + return true; + } + + if (!$this->isVirtual()->yes()) { + return true; + } + + return $this->hasHook('set'); } public function getDeprecatedDescription(): ?string diff --git a/tests/PHPStan/Rules/Properties/WritingToReadOnlyPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/WritingToReadOnlyPropertiesRuleTest.php index fb64553a3b..3dd095b13f 100644 --- a/tests/PHPStan/Rules/Properties/WritingToReadOnlyPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/WritingToReadOnlyPropertiesRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -87,4 +88,23 @@ public function testConflictingAnnotationProperty(): void ]); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/writing-to-read-only-hooked-properties.php'], [ + [ + 'Property WritingToReadOnlyHookedProperties\Foo::$i is not writable.', + 16, + ], + [ + 'Property WritingToReadOnlyHookedProperties\Bar::$i is not writable.', + 32, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/writing-to-read-only-hooked-properties.php b/tests/PHPStan/Rules/Properties/data/writing-to-read-only-hooked-properties.php new file mode 100644 index 0000000000..06953a9661 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/writing-to-read-only-hooked-properties.php @@ -0,0 +1,49 @@ += 8.4 + +namespace WritingToReadOnlyHookedProperties; + +interface Foo +{ + + public int $i { + // virtual, not writable + get; + } + +} + +function (Foo $f): void { + $f->i = 1; +}; + +class Bar +{ + + public int $i { + // virtual, not writable + get { + return 1; + } + } + +} + +function (Bar $b): void { + $b->i = 1; +}; + +class Baz +{ + + public int $i { + // backed, writable + get { + return $this->i + 1; + } + } + +} + +function (Baz $b): void { + $b->i = 1; +}; From 69f28152533b91d90759c62e3cae017c22aa5594 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Dec 2024 16:06:04 +0100 Subject: [PATCH 16/41] Test ReadingWriteOnlyPropertiesRule for hooked properties --- src/Reflection/Php/PhpPropertyReflection.php | 10 +++- .../ReadingWriteOnlyPropertiesRuleTest.php | 20 ++++++++ .../reading-write-only-hooked-properties.php | 51 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Rules/Properties/data/reading-write-only-hooked-properties.php diff --git a/src/Reflection/Php/PhpPropertyReflection.php b/src/Reflection/Php/PhpPropertyReflection.php index d8ce00cdf6..e9aaa764bc 100644 --- a/src/Reflection/Php/PhpPropertyReflection.php +++ b/src/Reflection/Php/PhpPropertyReflection.php @@ -174,7 +174,15 @@ public function getNativeType(): Type public function isReadable(): bool { - return true; + if ($this->isStatic()) { + return true; + } + + if (!$this->isVirtual()->yes()) { + return true; + } + + return $this->hasHook('get'); } public function isWritable(): bool diff --git a/tests/PHPStan/Rules/Properties/ReadingWriteOnlyPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/ReadingWriteOnlyPropertiesRuleTest.php index f3ba064bac..0857934cb5 100644 --- a/tests/PHPStan/Rules/Properties/ReadingWriteOnlyPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/ReadingWriteOnlyPropertiesRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -88,4 +89,23 @@ public function testConflictingAnnotationProperty(): void $this->analyse([__DIR__ . '/data/conflicting-annotation-property.php'], []); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->checkThisOnly = false; + $this->analyse([__DIR__ . '/data/reading-write-only-hooked-properties.php'], [ + [ + 'Property ReadingWriteOnlyHookedProperties\Foo::$i is not readable.', + 16, + ], + [ + 'Property ReadingWriteOnlyHookedProperties\Bar::$i is not readable.', + 34, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/reading-write-only-hooked-properties.php b/tests/PHPStan/Rules/Properties/data/reading-write-only-hooked-properties.php new file mode 100644 index 0000000000..9f695e4573 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/reading-write-only-hooked-properties.php @@ -0,0 +1,51 @@ += 8.4 + +namespace ReadingWriteOnlyHookedProperties; + +interface Foo +{ + + public int $i { + // virtual, not readable + set; + } + +} + +function (Foo $f): void { + echo $f->i; +}; + +class Bar +{ + + public int $other; + + public int $i { + // virtual, not readable + set { + $this->other = 1; + } + } + +} + +function (Bar $b): void { + echo $b->i; +}; + +class Baz +{ + + public int $i { + // backed, readable + set { + $this->i = 1; + } + } + +} + +function (Baz $b): void { + $b->i = 1; +}; From 81f8ed37e984c463c5df01ec3fc871ac90d69024 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Dec 2024 17:02:51 +0100 Subject: [PATCH 17/41] Test generics in TypesAssignedToPropertiesRule for hooked properties --- .../TypesAssignedToPropertiesRuleTest.php | 17 +++++ .../data/assign-hooked-properties.php | 73 +++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php index dd5c31608d..b374da4943 100644 --- a/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php +++ b/tests/PHPStan/Rules/Properties/TypesAssignedToPropertiesRuleTest.php @@ -741,6 +741,23 @@ public function testPropertyHooks(): void 'Property AssignHookedProperties\Foo::$i (array|int) does not accept array.', 27, ], + [ + 'Property AssignHookedProperties\FooGenerics::$a (int) does not accept string.', + 52, + ], + [ + 'Property AssignHookedProperties\FooGenerics::$a (T) does not accept int.', + 61, + 'Type int is not always the same as T. It breaks the contract for some argument types, typically subtypes.', + ], + [ + 'Property AssignHookedProperties\FooGenericsParam::$a (array) does not accept array|int.', + 76, + ], + [ + 'Property AssignHookedProperties\FooGenericsParam::$a (array|int) does not accept array.', + 91, + ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/assign-hooked-properties.php b/tests/PHPStan/Rules/Properties/data/assign-hooked-properties.php index d25a9f8b4b..6c6872df24 100644 --- a/tests/PHPStan/Rules/Properties/data/assign-hooked-properties.php +++ b/tests/PHPStan/Rules/Properties/data/assign-hooked-properties.php @@ -28,3 +28,76 @@ public function doFoo(): void } } + +/** + * @template T + */ +class FooGenerics +{ + + /** @var T */ + public $a { + set { + $this->a = $value; + } + } + + /** + * @param FooGenerics $f + * @return void + */ + public static function doFoo(self $f): void + { + $f->a = 1; + $f->a = 'foo'; + } + + /** + * @param T $t + */ + public function doBar($t): void + { + $this->a = $t; + $this->a = 1; + } + +} + +/** + * @template T + */ +class FooGenericsParam +{ + + /** @var array */ + public array $a { + /** @param array|int $value */ + set (array|int $value) { + $this->a = $value; // not ok + + if (is_array($value)) { + $this->a = $value; // ok + } + } + } + + /** + * @param FooGenericsParam $f + * @return void + */ + public static function doFoo(self $f): void + { + $f->a = [1]; // ok + $f->a = ['foo']; // not ok + } + + /** + * @param T $t + */ + public function doBar($t): void + { + $this->a = [$t]; // ok + $this->a = 1; // ok + } + +} From 3d5a204da107fa87835deac7af7944de938c13c1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Mon, 16 Dec 2024 17:14:10 +0100 Subject: [PATCH 18/41] CleaningParser - clean up property hooks --- Makefile | 2 + build/collision-detector.json | 2 + src/Parser/CleaningVisitor.php | 32 +++++++++++--- tests/PHPStan/Parser/CleaningParserTest.php | 9 +++- .../data/cleaning-property-hooks-after.php | 21 ++++++++++ .../data/cleaning-property-hooks-before.php | 42 +++++++++++++++++++ 6 files changed, 101 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Parser/data/cleaning-property-hooks-after.php create mode 100644 tests/PHPStan/Parser/data/cleaning-property-hooks-before.php diff --git a/Makefile b/Makefile index 407333c955..8a8077a3e1 100644 --- a/Makefile +++ b/Makefile @@ -90,6 +90,8 @@ lint: --exclude tests/PHPStan/Rules/Classes/data/bug-12281.php \ --exclude tests/PHPStan/Rules/Traits/data/bug-12281.php \ --exclude tests/PHPStan/Rules/Classes/data/invalid-hooked-properties.php \ + --exclude tests/PHPStan/Parser/data/cleaning-property-hooks-before.php \ + --exclude tests/PHPStan/Parser/data/cleaning-property-hooks-after.php \ src tests cs: diff --git a/build/collision-detector.json b/build/collision-detector.json index 12de9af1d3..03c717dfff 100644 --- a/build/collision-detector.json +++ b/build/collision-detector.json @@ -5,6 +5,8 @@ "../tests/PHPStan/Analyser/data/multipleParseErrors.php", "../tests/PHPStan/Parser/data/cleaning-1-before.php", "../tests/PHPStan/Parser/data/cleaning-1-after.php", + "../tests/PHPStan/Parser/data/cleaning-property-hooks-before.php", + "../tests/PHPStan/Parser/data/cleaning-property-hooks-after.php", "../tests/PHPStan/Rules/Functions/data/duplicate-function.php", "../tests/PHPStan/Rules/Classes/data/duplicate-class.php", "../tests/PHPStan/Rules/Names/data/multiple-namespaces.php", diff --git a/src/Parser/CleaningVisitor.php b/src/Parser/CleaningVisitor.php index 773c36f6e4..eb9492f3cd 100644 --- a/src/Parser/CleaningVisitor.php +++ b/src/Parser/CleaningVisitor.php @@ -7,6 +7,7 @@ use PhpParser\NodeVisitorAbstract; use PHPStan\Reflection\ParametersAcceptor; use function in_array; +use function is_array; final class CleaningVisitor extends NodeVisitorAbstract { @@ -21,20 +22,28 @@ public function __construct() public function enterNode(Node $node): ?Node { if ($node instanceof Node\Stmt\Function_) { - $node->stmts = $this->keepVariadicsAndYields($node->stmts); + $node->stmts = $this->keepVariadicsAndYields($node->stmts, null); return $node; } if ($node instanceof Node\Stmt\ClassMethod && $node->stmts !== null) { - $node->stmts = $this->keepVariadicsAndYields($node->stmts); + $node->stmts = $this->keepVariadicsAndYields($node->stmts, null); return $node; } if ($node instanceof Node\Expr\Closure) { - $node->stmts = $this->keepVariadicsAndYields($node->stmts); + $node->stmts = $this->keepVariadicsAndYields($node->stmts, null); return $node; } + if ($node instanceof Node\PropertyHook && is_array($node->body)) { + $propertyName = $node->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + if ($propertyName !== null) { + $node->body = $this->keepVariadicsAndYields($node->body, $propertyName); + return $node; + } + } + return null; } @@ -42,9 +51,9 @@ public function enterNode(Node $node): ?Node * @param Node\Stmt[] $stmts * @return Node\Stmt[] */ - private function keepVariadicsAndYields(array $stmts): array + private function keepVariadicsAndYields(array $stmts, ?string $hookedPropertyName): array { - $results = $this->nodeFinder->find($stmts, static function (Node $node): bool { + $results = $this->nodeFinder->find($stmts, static function (Node $node) use ($hookedPropertyName): bool { if ($node instanceof Node\Expr\YieldFrom || $node instanceof Node\Expr\Yield_) { return true; } @@ -56,6 +65,18 @@ private function keepVariadicsAndYields(array $stmts): array return true; } + if ($hookedPropertyName !== null) { + if ( + $node instanceof Node\Expr\PropertyFetch + && $node->var instanceof Node\Expr\Variable + && $node->var->name === 'this' + && $node->name instanceof Node\Identifier + && $node->name->toString() === $hookedPropertyName + ) { + return true; + } + } + return false; }); $newStmts = []; @@ -65,6 +86,7 @@ private function keepVariadicsAndYields(array $stmts): array || $result instanceof Node\Expr\YieldFrom || $result instanceof Node\Expr\Closure || $result instanceof Node\Expr\ArrowFunction + || $result instanceof Node\Expr\PropertyFetch ) { $newStmts[] = new Node\Stmt\Expression($result); continue; diff --git a/tests/PHPStan/Parser/CleaningParserTest.php b/tests/PHPStan/Parser/CleaningParserTest.php index e0afccabb7..8dbf569171 100644 --- a/tests/PHPStan/Parser/CleaningParserTest.php +++ b/tests/PHPStan/Parser/CleaningParserTest.php @@ -4,7 +4,7 @@ use PhpParser\Lexer\Emulative; use PhpParser\NodeVisitor\NameResolver; -use PhpParser\Parser\Php7; +use PhpParser\Parser\Php8; use PHPStan\File\FileReader; use PHPStan\Node\Printer\Printer; use PHPStan\Php\PhpVersion; @@ -52,6 +52,11 @@ public function dataParse(): iterable __DIR__ . '/data/cleaning-php-version-after-74.php', 70400, ], + [ + __DIR__ . '/data/cleaning-property-hooks-before.php', + __DIR__ . '/data/cleaning-property-hooks-after.php', + 80400, + ], ]; } @@ -66,7 +71,7 @@ public function testParse( { $parser = new CleaningParser( new SimpleParser( - new Php7(new Emulative()), + new Php8(new Emulative()), new NameResolver(), new VariadicMethodsVisitor(), new VariadicFunctionsVisitor(), diff --git a/tests/PHPStan/Parser/data/cleaning-property-hooks-after.php b/tests/PHPStan/Parser/data/cleaning-property-hooks-after.php new file mode 100644 index 0000000000..105bcf5d76 --- /dev/null +++ b/tests/PHPStan/Parser/data/cleaning-property-hooks-after.php @@ -0,0 +1,21 @@ +i; + } + } +} +class FooParam +{ + public function __construct(public int $i { + get { + $this->i; + } + }) + { + } +} diff --git a/tests/PHPStan/Parser/data/cleaning-property-hooks-before.php b/tests/PHPStan/Parser/data/cleaning-property-hooks-before.php new file mode 100644 index 0000000000..7b73ef3d09 --- /dev/null +++ b/tests/PHPStan/Parser/data/cleaning-property-hooks-before.php @@ -0,0 +1,42 @@ +j; + + // backed property, leave this here + return $this->i; + } + } + +} + +class FooParam +{ + + public function __construct( + public int $i { + get { + echo 'irrelevant'; + + // other property, clean up + echo $this->j; + + // backed property, leave this here + return $this->i; + } + } + ) + { + + } + +} From 2da91f8af6a2ac0ba7acf9d23d0fe36794d99dd3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 18 Dec 2024 10:18:05 +0100 Subject: [PATCH 19/41] Fix ReadOnlyByPhpDocPropertyAssignRule for hooked properties --- .../ReadOnlyByPhpDocPropertyAssignRule.php | 13 ++++++++++ ...ReadOnlyByPhpDocPropertyAssignRuleTest.php | 18 +++++++++++++ ...operty-hooks-readonly-by-phpdoc-assign.php | 25 +++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 tests/PHPStan/Rules/Properties/data/property-hooks-readonly-by-phpdoc-assign.php diff --git a/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php index c71ce58bbc..29d913d779 100644 --- a/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php +++ b/src/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRule.php @@ -7,6 +7,7 @@ use PHPStan\Node\PropertyAssignNode; use PHPStan\Reflection\ConstructorsHelper; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\ShouldNotHappenException; @@ -40,6 +41,18 @@ public function processNode(Node $node, Scope $scope): array return []; } + $inFunction = $scope->getFunction(); + if ( + $inFunction instanceof PhpMethodFromParserNodeReflection + && $inFunction->isPropertyHook() + && $propertyFetch->var instanceof Node\Expr\Variable + && $propertyFetch->var->name === 'this' + && $propertyFetch->name instanceof Node\Identifier + && $inFunction->getHookedPropertyName() === $propertyFetch->name->toString() + ) { + return []; + } + $errors = []; $reflections = $this->propertyReflectionFinder->findPropertyReflectionsFromNode($propertyFetch, $scope); foreach ($reflections as $propertyReflection) { diff --git a/tests/PHPStan/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRuleTest.php index 70d5379701..c7ec6ed0ad 100644 --- a/tests/PHPStan/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/ReadOnlyByPhpDocPropertyAssignRuleTest.php @@ -178,4 +178,22 @@ public function testFeature11775(): void ]); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/property-hooks-readonly-by-phpdoc-assign.php'], [ + [ + '@readonly property PropertyHooksReadonlyByPhpDocAssign\Foo::$i is assigned outside of the constructor.', + 15, + ], + [ + '@readonly property PropertyHooksReadonlyByPhpDocAssign\Foo::$j is assigned outside of the constructor.', + 17, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/data/property-hooks-readonly-by-phpdoc-assign.php b/tests/PHPStan/Rules/Properties/data/property-hooks-readonly-by-phpdoc-assign.php new file mode 100644 index 0000000000..179dfdde04 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/property-hooks-readonly-by-phpdoc-assign.php @@ -0,0 +1,25 @@ += 8.4 + +namespace PropertyHooksReadonlyByPhpDocAssign; + +class Foo +{ + + /** @readonly */ + public int $i { + get { + return $this->i + 1; + } + set { + $self = new self(); + $self->i = 1; + + $this->j = 2; + $this->i = $value - 1; + } + } + + /** @readonly */ + public int $j; + +} From a9202b030415043b62b70d7389f63da23004e18d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 18 Dec 2024 11:16:27 +0100 Subject: [PATCH 20/41] Introduce PropertyHookReturnStatementsNode similar to MethodReturnStatementsNode --- src/Analyser/NodeScopeResolver.php | 43 +++++++- src/Node/PropertyHookReturnStatementsNode.php | 104 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/Node/PropertyHookReturnStatementsNode.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7ad28508ac..53f0f97805 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -114,6 +114,7 @@ use PHPStan\Node\MethodReturnStatementsNode; use PHPStan\Node\NoopExpressionNode; use PHPStan\Node\PropertyAssignNode; +use PHPStan\Node\PropertyHookReturnStatementsNode; use PHPStan\Node\PropertyHookStatementNode; use PHPStan\Node\ReturnStatement; use PHPStan\Node\StaticMethodCallableNode; @@ -4702,7 +4703,47 @@ private function processPropertyHooks( $this->processExprNode($stmt, $hook->body, $hookScope, $nodeCallback, ExpressionContext::createTopLevel()); $nodeCallback(new PropertyAssignNode(new PropertyFetch(new Variable('this'), $propertyName, $hook->body->getAttributes()), $hook->body, false), $hookScope); } elseif (is_array($hook->body)) { - $this->processStmtNodes(new PropertyHookStatementNode($hook), $hook->body, $hookScope, $nodeCallback, StatementContext::createTopLevel()); + $gatheredReturnStatements = []; + $executionEnds = []; + $methodImpurePoints = []; + $statementResult = $this->processStmtNodes(new PropertyHookStatementNode($hook), $hook->body, $hookScope, static function (Node $node, Scope $scope) use ($nodeCallback, $hookScope, &$gatheredReturnStatements, &$executionEnds, &$hookImpurePoints): void { + $nodeCallback($node, $scope); + if ($scope->getFunction() !== $hookScope->getFunction()) { + return; + } + if ($scope->isInAnonymousFunction()) { + return; + } + if ($node instanceof PropertyAssignNode) { + $hookImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if (!$node instanceof Return_) { + return; + } + + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }, StatementContext::createTopLevel()); + + $nodeCallback(new PropertyHookReturnStatementsNode( + $hook, + $gatheredReturnStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $methodImpurePoints), + $classReflection, + $hookReflection, + ), $hookScope); } } diff --git a/src/Node/PropertyHookReturnStatementsNode.php b/src/Node/PropertyHookReturnStatementsNode.php new file mode 100644 index 0000000000..7d97a140b9 --- /dev/null +++ b/src/Node/PropertyHookReturnStatementsNode.php @@ -0,0 +1,104 @@ + $returnStatements + * @param list $executionEnds + * @param ImpurePoint[] $impurePoints + */ + public function __construct( + private PropertyHook $hook, + private array $returnStatements, + private StatementResult $statementResult, + private array $executionEnds, + private array $impurePoints, + private ClassReflection $classReflection, + private PhpMethodFromParserNodeReflection $hookReflection, + ) + { + parent::__construct($hook->getAttributes()); + } + + public function getPropertyHookNode(): PropertyHook + { + return $this->hook; + } + + public function returnsByRef(): bool + { + return $this->hook->byRef; + } + + public function hasNativeReturnTypehint(): bool + { + return false; + } + + public function getYieldStatements(): array + { + return []; + } + + public function isGenerator(): bool + { + return false; + } + + public function getReturnStatements(): array + { + return $this->returnStatements; + } + + public function getStatementResult(): StatementResult + { + return $this->statementResult; + } + + public function getExecutionEnds(): array + { + return $this->executionEnds; + } + + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function getClassReflection(): ClassReflection + { + return $this->classReflection; + } + + public function getHookReflection(): PhpMethodFromParserNodeReflection + { + return $this->hookReflection; + } + + public function getType(): string + { + return 'PHPStan_Node_PropertyHookReturnStatementsNode'; + } + + /** + * @return string[] + */ + public function getSubNodeNames(): array + { + return []; + } + +} From c72e822c602c3f85265a926df042e9f42e84efa9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Wed, 18 Dec 2024 11:21:26 +0100 Subject: [PATCH 21/41] SetNonVirtualPropertyHookAssignRule - level 3 --- conf/config.level3.neon | 1 + .../SetNonVirtualPropertyHookAssignRule.php | 97 +++++++++++++++++++ ...etNonVirtualPropertyHookAssignRuleTest.php | 38 ++++++++ .../set-non-virtual-property-hook-assign.php | 68 +++++++++++++ 4 files changed, 204 insertions(+) create mode 100644 src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php create mode 100644 tests/PHPStan/Rules/Properties/SetNonVirtualPropertyHookAssignRuleTest.php create mode 100644 tests/PHPStan/Rules/Properties/data/set-non-virtual-property-hook-assign.php diff --git a/conf/config.level3.neon b/conf/config.level3.neon index 31e065bf6f..6522c1eb5b 100644 --- a/conf/config.level3.neon +++ b/conf/config.level3.neon @@ -20,6 +20,7 @@ rules: - PHPStan\Rules\Properties\ReadOnlyByPhpDocPropertyAssignRule - PHPStan\Rules\Properties\ReadOnlyPropertyAssignRefRule - PHPStan\Rules\Properties\ReadOnlyByPhpDocPropertyAssignRefRule + - PHPStan\Rules\Properties\SetNonVirtualPropertyHookAssignRule - PHPStan\Rules\Properties\ShortGetPropertyHookReturnTypeRule - PHPStan\Rules\Properties\TypesAssignedToPropertiesRule - PHPStan\Rules\Variables\ParameterOutAssignedTypeRule diff --git a/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php b/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php new file mode 100644 index 0000000000..67f8f134bb --- /dev/null +++ b/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php @@ -0,0 +1,97 @@ + + */ +final class SetNonVirtualPropertyHookAssignRule implements Rule +{ + + public function getNodeType(): string + { + return PropertyHookReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $hookNode = $node->getPropertyHookNode(); + if ($hookNode->name->toLowerString() !== 'set') { + return []; + } + + $hookReflection = $node->getHookReflection(); + if (!$hookReflection->isPropertyHook()) { + throw new ShouldNotHappenException(); + } + + $propertyName = $hookReflection->getHookedPropertyName(); + $classReflection = $node->getClassReflection(); + if (!$classReflection->hasNativeProperty($propertyName)) { + throw new ShouldNotHappenException(); + } + + $propertyReflection = $classReflection->getNativeProperty($propertyName); + if ($propertyReflection->isVirtual()->yes()) { + return []; + } + + $finalHookScope = null; + foreach ($node->getExecutionEnds() as $executionEnd) { + $statementResult = $executionEnd->getStatementResult(); + $endNode = $executionEnd->getNode(); + if ($statementResult->isAlwaysTerminating()) { + if ($endNode instanceof Node\Stmt\Expression) { + $exprType = $statementResult->getScope()->getType($endNode->expr); + if ($exprType instanceof NeverType && $exprType->isExplicit()) { + continue; + } + } + } + if ($finalHookScope === null) { + $finalHookScope = $statementResult->getScope(); + continue; + } + + $finalHookScope = $finalHookScope->mergeWith($statementResult->getScope()); + } + + foreach ($node->getReturnStatements() as $returnStatement) { + if ($finalHookScope === null) { + $finalHookScope = $returnStatement->getScope(); + continue; + } + $finalHookScope = $finalHookScope->mergeWith($returnStatement->getScope()); + } + + if ($finalHookScope === null) { + return []; + } + + $initExpr = new PropertyInitializationExpr($propertyName); + $hasInit = $finalHookScope->hasExpressionType($initExpr); + if ($hasInit->yes()) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + 'Set hook for non-virtual property %s::$%s does not %sassign value to it.', + $classReflection->getDisplayName(), + $propertyName, + $hasInit->maybe() ? 'always ' : '', + ))->identifier('propertySetHook.noAssign')->build(), + ]; + } + +} diff --git a/tests/PHPStan/Rules/Properties/SetNonVirtualPropertyHookAssignRuleTest.php b/tests/PHPStan/Rules/Properties/SetNonVirtualPropertyHookAssignRuleTest.php new file mode 100644 index 0000000000..bdfcaf5f27 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/SetNonVirtualPropertyHookAssignRuleTest.php @@ -0,0 +1,38 @@ + + */ +class SetNonVirtualPropertyHookAssignRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new SetNonVirtualPropertyHookAssignRule(); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/set-non-virtual-property-hook-assign.php'], [ + [ + 'Set hook for non-virtual property SetNonVirtualPropertyHookAssign\Foo::$k does not assign value to it.', + 24, + ], + [ + 'Set hook for non-virtual property SetNonVirtualPropertyHookAssign\Foo::$k2 does not always assign value to it.', + 34, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/set-non-virtual-property-hook-assign.php b/tests/PHPStan/Rules/Properties/data/set-non-virtual-property-hook-assign.php new file mode 100644 index 0000000000..ffc0e559f6 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/set-non-virtual-property-hook-assign.php @@ -0,0 +1,68 @@ += 8.4 + +namespace SetNonVirtualPropertyHookAssign; + +class Foo +{ + + public int $i { + get { + return 1; + } + set { + // virtual property + $this->j = $value; + } + } + + public int $j; + + public int $k { + get { + return $this->k + 1; + } + set { + // backed property, missing assign should be reported + $this->j = $value; + } + } + + public int $k2 { + get { + return $this->k2 + 1; + } + set { + // backed property, missing assign should be reported + if (rand(0, 1)) { + return; + } + + $this->k2 = $value; + } + } + + public int $k3 { + get { + return $this->k3 + 1; + } + set { + // backed property, always assigned (or throws) + if (rand(0, 1)) { + throw new \Exception(); + } + + $this->k3 = $value; + } + } + + public int $k4 { + get { + return $this->k4 + 1; + } + set { + // backed property, always assigned + $this->k4 = $value; + } + } + +} From abe0eaa3d6c02d786ca4eb6ffb2103285aa2cc78 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 19 Dec 2024 10:25:12 +0100 Subject: [PATCH 22/41] GetNonVirtualPropertyHookReadRule - level 3 --- conf/config.level3.neon | 1 + .../GetNonVirtualPropertyHookReadRule.php | 118 ++++++++++++++++++ .../GetNonVirtualPropertyHookReadRuleTest.php | 38 ++++++ .../get-non-virtual-property-hook-read.php | 48 +++++++ 4 files changed, 205 insertions(+) create mode 100644 src/Rules/Properties/GetNonVirtualPropertyHookReadRule.php create mode 100644 tests/PHPStan/Rules/Properties/GetNonVirtualPropertyHookReadRuleTest.php create mode 100644 tests/PHPStan/Rules/Properties/data/get-non-virtual-property-hook-read.php diff --git a/conf/config.level3.neon b/conf/config.level3.neon index 6522c1eb5b..b7d1a4c15e 100644 --- a/conf/config.level3.neon +++ b/conf/config.level3.neon @@ -16,6 +16,7 @@ rules: - PHPStan\Rules\Generators\YieldTypeRule - PHPStan\Rules\Methods\ReturnTypeRule - PHPStan\Rules\Properties\DefaultValueTypesAssignedToPropertiesRule + - PHPStan\Rules\Properties\GetNonVirtualPropertyHookReadRule - PHPStan\Rules\Properties\ReadOnlyPropertyAssignRule - PHPStan\Rules\Properties\ReadOnlyByPhpDocPropertyAssignRule - PHPStan\Rules\Properties\ReadOnlyPropertyAssignRefRule diff --git a/src/Rules/Properties/GetNonVirtualPropertyHookReadRule.php b/src/Rules/Properties/GetNonVirtualPropertyHookReadRule.php new file mode 100644 index 0000000000..61b1c32a6c --- /dev/null +++ b/src/Rules/Properties/GetNonVirtualPropertyHookReadRule.php @@ -0,0 +1,118 @@ + + */ +final class GetNonVirtualPropertyHookReadRule implements Rule +{ + + public function getNodeType(): string + { + return ClassPropertiesNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $reads = []; + $classReflection = $node->getClassReflection(); + foreach ($node->getPropertyUsages() as $propertyUsage) { + if (!$propertyUsage instanceof PropertyRead) { + continue; + } + + $fetch = $propertyUsage->getFetch(); + if (!$fetch instanceof Node\Expr\PropertyFetch) { + continue; + } + + if (!$fetch->name instanceof Node\Identifier) { + continue; + } + + $propertyName = $fetch->name->toString(); + if (!$fetch->var instanceof Node\Expr\Variable || $fetch->var->name !== 'this') { + continue; + } + + $usageScope = $propertyUsage->getScope(); + $inFunction = $usageScope->getFunction(); + if (!$inFunction instanceof PhpMethodFromParserNodeReflection) { + continue; + } + + if (!$inFunction->isPropertyHook()) { + continue; + } + + if ($inFunction->getPropertyHookName() !== 'get') { + continue; + } + + if ($propertyName !== $inFunction->getHookedPropertyName()) { + continue; + } + + $reads[$propertyName] = true; + } + + $errors = []; + foreach ($node->getProperties() as $propertyNode) { + if (!$propertyNode->hasHooks()) { + continue; + } + + if (array_key_exists($propertyNode->getName(), $reads)) { + continue; + } + + $propertyReflection = $classReflection->getNativeProperty($propertyNode->getName()); + if ($propertyReflection->isVirtual()->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + 'Get hook for non-virtual property %s::$%s does not read its value.', + $classReflection->getDisplayName(), + $propertyNode->getName(), + )) + ->line($this->getGetHookLine($propertyNode)) + ->identifier('propertyGetHook.noRead') + ->build(); + } + + return $errors; + } + + private function getGetHookLine(ClassPropertyNode $propertyNode): int + { + $getHook = null; + foreach ($propertyNode->getHooks() as $hook) { + if ($hook->name->toLowerString() !== 'get') { + continue; + } + + $getHook = $hook; + break; + } + + if ($getHook === null) { + return $propertyNode->getStartLine(); + } + + return $getHook->getStartLine(); + } + +} diff --git a/tests/PHPStan/Rules/Properties/GetNonVirtualPropertyHookReadRuleTest.php b/tests/PHPStan/Rules/Properties/GetNonVirtualPropertyHookReadRuleTest.php new file mode 100644 index 0000000000..8eedc78214 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/GetNonVirtualPropertyHookReadRuleTest.php @@ -0,0 +1,38 @@ + + */ +class GetNonVirtualPropertyHookReadRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new GetNonVirtualPropertyHookReadRule(); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/get-non-virtual-property-hook-read.php'], [ + [ + 'Get hook for non-virtual property GetNonVirtualPropertyHookRead\Foo::$k does not read its value.', + 24, + ], + [ + 'Get hook for non-virtual property GetNonVirtualPropertyHookRead\Foo::$l does not read its value.', + 30, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/get-non-virtual-property-hook-read.php b/tests/PHPStan/Rules/Properties/data/get-non-virtual-property-hook-read.php new file mode 100644 index 0000000000..077792c406 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/get-non-virtual-property-hook-read.php @@ -0,0 +1,48 @@ += 8.4 + +namespace GetNonVirtualPropertyHookRead; + +class Foo +{ + + public int $i { + // backed, read and written + get => $this->i + 1; + set => $this->i + $value; + } + + public int $j { + // virtual + get => 1; + set { + $this->a = $value; + } + } + + public int $k { + // backed, not read + get => 1; + set => $value + 1; + } + + public int $l { + // backed, not read, long get + get { + return 1; + } + set => $value + 1; + } + + public int $m { + // it is okay to only read it sometimes + get { + if (rand(0, 1)) { + return 1; + } + + return $this->m; + } + set => $value + 1; + } + +} From 7f6cd04b7e05034f36afb265372ee2b09e15588b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 19 Dec 2024 13:11:50 +0100 Subject: [PATCH 23/41] Test ReturnNullsafeByRefRule with property hooks --- .../Functions/ReturnNullsafeByRefRuleTest.php | 15 +++++++++++++++ .../return-null-safe-by-ref-property-hooks.php | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/PHPStan/Rules/Functions/data/return-null-safe-by-ref-property-hooks.php diff --git a/tests/PHPStan/Rules/Functions/ReturnNullsafeByRefRuleTest.php b/tests/PHPStan/Rules/Functions/ReturnNullsafeByRefRuleTest.php index 92b6429eb0..a3b8f1db9e 100644 --- a/tests/PHPStan/Rules/Functions/ReturnNullsafeByRefRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ReturnNullsafeByRefRuleTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\NullsafeCheck; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -35,4 +36,18 @@ public function testRule(): void ]); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/return-null-safe-by-ref-property-hooks.php'], [ + [ + 'Nullsafe cannot be returned by reference.', + 13, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/return-null-safe-by-ref-property-hooks.php b/tests/PHPStan/Rules/Functions/data/return-null-safe-by-ref-property-hooks.php new file mode 100644 index 0000000000..f902fa9f00 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/return-null-safe-by-ref-property-hooks.php @@ -0,0 +1,16 @@ += 8.4 + +namespace ReturnNullSafeByRefPropertyHools; + +use stdClass; + +class Foo +{ + public int $i { + &get { + $foo = new stdClass(); + + return $foo?->foo; + } + } +} From a72355efedce0da6338ca1a03367133ca921bfab Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 19 Dec 2024 13:23:11 +0100 Subject: [PATCH 24/41] Support `#[Deprecated]` attribute in property hooks --- src/Analyser/MutatingScope.php | 6 ++- src/Analyser/NodeScopeResolver.php | 6 ++- src/Reflection/InitializerExprContext.php | 3 +- .../Annotations/DeprecatedAnnotationsTest.php | 50 +++++++++++++++++++ ...hpFunctionFromParserReflectionRuleTest.php | 36 +++++++++++-- .../deprecated-attribute-property-hooks.php | 37 ++++++++++++++ 6 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-property-hooks.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index bfa07d782d..5bd3f0a86e 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2999,6 +2999,8 @@ public function enterPropertyHook( ?Type $phpDocPropertyType, array $phpDocParameterTypes, ?Type $throwType, + ?string $deprecatedDescription, + bool $isDeprecated, ?string $phpDocComment, ): self { @@ -3054,8 +3056,8 @@ public function enterPropertyHook( $realReturnType, $phpDocReturnType, $throwType, - null, - false, + $deprecatedDescription, + $isDeprecated, false, false, false, diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 53f0f97805..1639a12522 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1985,7 +1985,7 @@ static function (Node $node, Scope $scope) use ($nodeCallback): void { /** * @return array{bool, string|null} */ - private function getDeprecatedAttribute(Scope $scope, Node\Stmt\Function_|Node\Stmt\ClassMethod $stmt): array + private function getDeprecatedAttribute(Scope $scope, Node\Stmt\Function_|Node\Stmt\ClassMethod|Node\PropertyHook $stmt): array { $initializerExprContext = InitializerExprContext::fromStubParameter( null, @@ -4684,6 +4684,8 @@ private function processPropertyHooks( $this->processParamNode($stmt, $param, $scope, $nodeCallback); } + [$isDeprecated, $deprecatedDescription] = $this->getDeprecatedAttribute($scope, $hook); + $hookScope = $scope->enterPropertyHook( $hook, $propertyName, @@ -4691,6 +4693,8 @@ private function processPropertyHooks( $phpDocType, $phpDocParameterTypes, $phpDocThrowType, + $deprecatedDescription, + $isDeprecated, $phpDocComment, ); $hookReflection = $hookScope->getFunction(); diff --git a/src/Reflection/InitializerExprContext.php b/src/Reflection/InitializerExprContext.php index 9d7d9aa5bf..f1587ef242 100644 --- a/src/Reflection/InitializerExprContext.php +++ b/src/Reflection/InitializerExprContext.php @@ -2,6 +2,7 @@ namespace PHPStan\Reflection; +use PhpParser\Node\PropertyHook; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Function_; use PHPStan\Analyser\Scope; @@ -115,7 +116,7 @@ public static function fromReflectionParameter(ReflectionParameter $parameter): public static function fromStubParameter( ?string $className, string $stubFile, - ClassMethod|Function_ $function, + ClassMethod|Function_|PropertyHook $function, ): self { $namespace = null; diff --git a/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php b/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php index ff57846f96..8daa5fc1ee 100644 --- a/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php +++ b/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php @@ -342,4 +342,54 @@ public function testDeprecatedAttributeAboveEnumCase(string $className, string $ $this->assertSame($deprecatedDescription, $case->getDeprecatedDescription()); } + public function dataDeprecatedAttributeAbovePropertyHook(): iterable + { + yield [ + 'DeprecatedAttributePropertyHooks\\Foo', + 'i', + 'get', + TrinaryLogic::createNo(), + null, + ]; + yield [ + 'DeprecatedAttributePropertyHooks\\Foo', + 'j', + 'get', + TrinaryLogic::createYes(), + null, + ]; + yield [ + 'DeprecatedAttributePropertyHooks\\Foo', + 'k', + 'get', + TrinaryLogic::createYes(), + 'msg', + ]; + yield [ + 'DeprecatedAttributePropertyHooks\\Foo', + 'l', + 'get', + TrinaryLogic::createYes(), + 'msg2', + ]; + } + + /** + * @dataProvider dataDeprecatedAttributeAbovePropertyHook + * @param 'get'|'set' $hookName + */ + public function testDeprecatedAttributeAbovePropertyHook(string $className, string $propertyName, string $hookName, TrinaryLogic $isDeprecated, ?string $deprecatedDescription): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $reflectionProvider = $this->createReflectionProvider(); + $class = $reflectionProvider->getClass($className); + $property = $class->getNativeProperty($propertyName); + $hook = $property->getHook($hookName); + $this->assertSame($isDeprecated->describe(), $hook->isDeprecated()->describe()); + $this->assertSame($deprecatedDescription, $hook->getDeprecatedDescription()); + } + } diff --git a/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php b/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php index ce520ef7aa..337713a18a 100644 --- a/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php +++ b/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php @@ -6,10 +6,12 @@ use PHPStan\Analyser\Scope; use PHPStan\Node\InClassMethodNode; use PHPStan\Node\InFunctionNode; +use PHPStan\Node\InPropertyHookNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Testing\RuleTestCase; use function sprintf; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -18,15 +20,15 @@ class DeprecatedAttributePhpFunctionFromParserReflectionRuleTest extends RuleTes { /** - * @return Rule + * @return Rule */ protected function getRule(): Rule { - return new /** @implements Rule */ class implements Rule { + return new /** @implements Rule */ class implements Rule { public function getNodeType(): string { - return Node\Stmt::class; + return Node::class; } public function processNode(Node $node, Scope $scope): array @@ -35,6 +37,8 @@ public function processNode(Node $node, Scope $scope): array $reflection = $node->getFunctionReflection(); } elseif ($node instanceof InClassMethodNode) { $reflection = $node->getMethodReflection(); + } elseif ($node instanceof InPropertyHookNode) { + $reflection = $node->getHookReflection(); } else { return []; } @@ -108,4 +112,30 @@ public function testMethodRule(): void ]); } + public function testPropertyHookRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/deprecated-attribute-property-hooks.php'], [ + [ + 'Not deprecated', + 11, + ], + [ + 'Deprecated', + 17, + ], + [ + 'Deprecated: msg', + 24, + ], + [ + 'Deprecated: msg2', + 31, + ], + ]); + } + } diff --git a/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-property-hooks.php b/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-property-hooks.php new file mode 100644 index 0000000000..3caf94adf3 --- /dev/null +++ b/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-property-hooks.php @@ -0,0 +1,37 @@ += 8.4 + +namespace DeprecatedAttributePropertyHooks; + +use Deprecated; + +class Foo +{ + + public int $i { + get { + return 1; + } + } + + public int $j { + #[Deprecated] + get { + return 1; + } + } + + public int $k { + #[Deprecated('msg')] + get { + return 1; + } + } + + public int $l { + #[Deprecated(since: '1.0', message: 'msg2')] + get { + return 1; + } + } + +} From 0bff31c21052557f62fe01891504f7e6b5641046 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 19 Dec 2024 13:46:16 +0100 Subject: [PATCH 25/41] Support magic `__PROPERTY__` constant in hooks --- src/Analyser/NodeScopeResolver.php | 2 +- src/Reflection/InitializerExprContext.php | 54 +++++++++++++++---- .../InitializerExprTypeResolver.php | 9 ++++ .../PHPStan/Analyser/nsrt/property-hooks.php | 18 +++++++ .../Annotations/DeprecatedAnnotationsTest.php | 7 +++ ...hpFunctionFromParserReflectionRuleTest.php | 4 ++ .../deprecated-attribute-property-hooks.php | 7 +++ 7 files changed, 91 insertions(+), 10 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 1639a12522..3bc323341c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1988,7 +1988,7 @@ static function (Node $node, Scope $scope) use ($nodeCallback): void { private function getDeprecatedAttribute(Scope $scope, Node\Stmt\Function_|Node\Stmt\ClassMethod|Node\PropertyHook $stmt): array { $initializerExprContext = InitializerExprContext::fromStubParameter( - null, + $scope->isInClass() ? $scope->getClassReflection()->getName() : null, $scope->getFile(), $stmt, ); diff --git a/src/Reflection/InitializerExprContext.php b/src/Reflection/InitializerExprContext.php index f1587ef242..eb64cacdbb 100644 --- a/src/Reflection/InitializerExprContext.php +++ b/src/Reflection/InitializerExprContext.php @@ -9,6 +9,8 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionFunction; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionParameter; use PHPStan\BetterReflection\Reflection\ReflectionConstant; +use PHPStan\Parser\PropertyHookNameVisitor; +use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\ShouldNotHappenException; use function array_slice; use function count; @@ -32,21 +34,25 @@ private function __construct( private ?string $traitName, private ?string $function, private ?string $method, + private ?string $property, ) { } public static function fromScope(Scope $scope): self { + $function = $scope->getFunction(); + return new self( $scope->getFile(), $scope->getNamespace(), $scope->isInClass() ? $scope->getClassReflection()->getName() : null, $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, - $scope->isInAnonymousFunction() ? '{closure}' : ($scope->getFunction() !== null ? $scope->getFunction()->getName() : null), - $scope->isInAnonymousFunction() ? '{closure}' : ($scope->getFunction() instanceof MethodReflection - ? sprintf('%s::%s', $scope->getFunction()->getDeclaringClass()->getName(), $scope->getFunction()->getName()) - : ($scope->getFunction() instanceof FunctionReflection ? $scope->getFunction()->getName() : null)), + $scope->isInAnonymousFunction() ? '{closure}' : ($function !== null ? $function->getName() : null), + $scope->isInAnonymousFunction() ? '{closure}' : ($function instanceof MethodReflection + ? sprintf('%s::%s', $function->getDeclaringClass()->getName(), $function->getName()) + : ($function instanceof FunctionReflection ? $function->getName() : null)), + $function instanceof PhpMethodFromParserNodeReflection && $function->isPropertyHook() ? $function->getHookedPropertyName() : null, ); } @@ -81,6 +87,7 @@ public static function fromClass(string $className, ?string $fileName): self null, null, null, + null, ); } @@ -96,6 +103,7 @@ public static function fromReflectionParameter(ReflectionParameter $parameter): null, $declaringFunction->getName(), $declaringFunction->getName(), + null, // Property hook parameter cannot have a default value. fromReflectionParameter is only used for that ); } @@ -110,6 +118,7 @@ public static function fromReflectionParameter(ReflectionParameter $parameter): $betterReflection->getDeclaringClass()->isTrait() ? $betterReflection->getDeclaringClass()->getName() : null, $declaringFunction->getName(), sprintf('%s::%s', $declaringFunction->getDeclaringClass()->getName(), $declaringFunction->getName()), + null, // Property hook parameter cannot have a default value. fromReflectionParameter is only used for that ); } @@ -127,15 +136,36 @@ public static function fromStubParameter( $namespace = self::parseNamespace($function->namespacedName->toString()); } } + + $functionName = null; + $propertyName = null; + if ($function instanceof Function_ && $function->namespacedName !== null) { + $functionName = $function->namespacedName->toString(); + } elseif ($function instanceof ClassMethod) { + $functionName = $function->name->toString(); + } elseif ($function instanceof PropertyHook) { + $propertyName = $function->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + $functionName = sprintf('$%s::%s', $propertyName, $function->name->toString()); + } + + $methodName = null; + if ($function instanceof ClassMethod && $className !== null) { + $methodName = sprintf('%s::%s', $className, $function->name->toString()); + } elseif ($function instanceof PropertyHook) { + $propertyName = $function->getAttribute(PropertyHookNameVisitor::ATTRIBUTE_NAME); + $methodName = sprintf('%s::$%s::%s', $className, $propertyName, $function->name->toString()); + } elseif ($function instanceof Function_ && $function->namespacedName !== null) { + $methodName = $function->namespacedName->toString(); + } + return new self( $stubFile, $namespace, $className, null, - $function instanceof Function_ && $function->namespacedName !== null ? $function->namespacedName->toString() : ($function instanceof ClassMethod ? $function->name->toString() : null), - $function instanceof ClassMethod && $className !== null - ? sprintf('%s::%s', $className, $function->name->toString()) - : ($function instanceof Function_ && $function->namespacedName !== null ? $function->namespacedName->toString() : null), + $functionName, + $methodName, + $propertyName, ); } @@ -148,12 +178,13 @@ public static function fromGlobalConstant(ReflectionConstant $constant): self null, null, null, + null, ); } public static function createEmpty(): self { - return new self(null, null, null, null, null, null); + return new self(null, null, null, null, null, null, null); } public function getFile(): ?string @@ -186,4 +217,9 @@ public function getMethod(): ?string return $this->method; } + public function getProperty(): ?string + { + return $this->property; + } + } diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index fc8ad21c17..9fef24a587 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -392,6 +392,15 @@ public function getType(Expr $expr, InitializerExprContext $context): Type return new ConstantStringType($context->getTraitName(), true); } + if ($expr instanceof MagicConst\Property) { + $contextProperty = $context->getProperty(); + if ($contextProperty === null) { + return new ConstantStringType(''); + } + + return new ConstantStringType($contextProperty); + } + if ($expr instanceof PropertyFetch && $expr->name instanceof Identifier) { $fetchedOnType = $this->getType($expr->var, $context); if (!$fetchedOnType->hasProperty($expr->name->name)->yes()) { diff --git a/tests/PHPStan/Analyser/nsrt/property-hooks.php b/tests/PHPStan/Analyser/nsrt/property-hooks.php index 4a7f0d9f3a..8e32e4c96d 100644 --- a/tests/PHPStan/Analyser/nsrt/property-hooks.php +++ b/tests/PHPStan/Analyser/nsrt/property-hooks.php @@ -357,3 +357,21 @@ public function doFoo3(): void } } + +class MagicConstants +{ + + public int $i { + get { + assertType("'\$i::get'", __FUNCTION__); + assertType("'PropertyHooksTypes\\\\MagicConstants::\$i::get'", __METHOD__); + assertType("'i'", __PROPERTY__); + } + set { + assertType("'\$i::set'", __FUNCTION__); + assertType("'PropertyHooksTypes\\\\MagicConstants::\$i::set'", __METHOD__); + assertType("'i'", __PROPERTY__); + } + } + +} diff --git a/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php b/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php index 8daa5fc1ee..be18a8fb4b 100644 --- a/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php +++ b/tests/PHPStan/Reflection/Annotations/DeprecatedAnnotationsTest.php @@ -372,6 +372,13 @@ public function dataDeprecatedAttributeAbovePropertyHook(): iterable TrinaryLogic::createYes(), 'msg2', ]; + yield [ + 'DeprecatedAttributePropertyHooks\\Foo', + 'm', + 'get', + TrinaryLogic::createYes(), + '$m::get+DeprecatedAttributePropertyHooks\Foo::$m::get+m', + ]; } /** diff --git a/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php b/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php index 337713a18a..efdfaece70 100644 --- a/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php +++ b/tests/PHPStan/Reflection/Annotations/DeprecatedAttributePhpFunctionFromParserReflectionRuleTest.php @@ -135,6 +135,10 @@ public function testPropertyHookRule(): void 'Deprecated: msg2', 31, ], + [ + 'Deprecated: $m::get+DeprecatedAttributePropertyHooks\Foo::$m::get+m', + 38, + ], ]); } diff --git a/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-property-hooks.php b/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-property-hooks.php index 3caf94adf3..0da2fbe4e5 100644 --- a/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-property-hooks.php +++ b/tests/PHPStan/Reflection/Annotations/data/deprecated-attribute-property-hooks.php @@ -34,4 +34,11 @@ class Foo } } + public int $m { + #[Deprecated(message: __FUNCTION__ . '+' . __METHOD__ . '+' . __PROPERTY__)] + get { + return 1; + } + } + } From fe255fed179b37b62bd0fa71206f5e4a27b514e6 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 19 Dec 2024 16:03:54 +0100 Subject: [PATCH 26/41] Test ContinueBreakInLoopRule for property hooks --- Makefile | 1 + .../Keywords/ContinueBreakInLoopRule.php | 6 +- .../Keywords/ContinueBreakInLoopRuleTest.php | 31 ++++++ .../data/continue-break-property-hook.php | 102 ++++++++++++++++++ 4 files changed, 135 insertions(+), 5 deletions(-) create mode 100644 tests/PHPStan/Rules/Keywords/data/continue-break-property-hook.php diff --git a/Makefile b/Makefile index 8a8077a3e1..fc4a0fe42e 100644 --- a/Makefile +++ b/Makefile @@ -43,6 +43,7 @@ lint: --exclude tests/PHPStan/Rules/Functions/data/arrow-function-nullsafe-by-ref.php \ --exclude tests/PHPStan/Levels/data/namedArguments.php \ --exclude tests/PHPStan/Rules/Keywords/data/continue-break.php \ + --exclude tests/PHPStan/Rules/Keywords/data/continue-break-property-hook.php \ --exclude tests/PHPStan/Rules/Properties/data/invalid-callable-property-type.php \ --exclude tests/PHPStan/Rules/Properties/data/properties-in-interface.php \ --exclude tests/PHPStan/Rules/Properties/data/read-only-property.php \ diff --git a/src/Rules/Keywords/ContinueBreakInLoopRule.php b/src/Rules/Keywords/ContinueBreakInLoopRule.php index 75657f232f..4f421e5a6c 100644 --- a/src/Rules/Keywords/ContinueBreakInLoopRule.php +++ b/src/Rules/Keywords/ContinueBreakInLoopRule.php @@ -39,11 +39,7 @@ public function processNode(Node $node, Scope $scope): array if ($parentStmtType === Stmt\Case_::class) { continue; } - if ( - $parentStmtType === Stmt\Function_::class - || $parentStmtType === Stmt\ClassMethod::class - || $parentStmtType === Node\Expr\Closure::class - ) { + if ($parentStmtType === Node\Expr\Closure::class) { return [ RuleErrorBuilder::message(sprintf( 'Keyword %s used outside of a loop or a switch statement.', diff --git a/tests/PHPStan/Rules/Keywords/ContinueBreakInLoopRuleTest.php b/tests/PHPStan/Rules/Keywords/ContinueBreakInLoopRuleTest.php index 493e23592b..bca307593c 100644 --- a/tests/PHPStan/Rules/Keywords/ContinueBreakInLoopRuleTest.php +++ b/tests/PHPStan/Rules/Keywords/ContinueBreakInLoopRuleTest.php @@ -4,6 +4,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -46,4 +47,34 @@ public function testRule(): void ]); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/continue-break-property-hook.php'], [ + [ + 'Keyword break used outside of a loop or a switch statement.', + 13, + ], + [ + 'Keyword break used outside of a loop or a switch statement.', + 15, + ], + [ + 'Keyword break used outside of a loop or a switch statement.', + 24, + ], + [ + 'Keyword continue used outside of a loop or a switch statement.', + 26, + ], + [ + 'Keyword break used outside of a loop or a switch statement.', + 35, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Keywords/data/continue-break-property-hook.php b/tests/PHPStan/Rules/Keywords/data/continue-break-property-hook.php new file mode 100644 index 0000000000..2cc1ba297b --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/continue-break-property-hook.php @@ -0,0 +1,102 @@ += 8.4 + +namespace ContinueBreakPropertyHook; + +class Foo +{ + + public int $bar { + set (int $foo) { + foreach ([1, 2, 3] as $val) { + switch ($foo) { + case 1: + break 3; + default: + break 3; + } + } + } + } + + public int $baz { + get { + if (rand(0, 1)) { + break; + } else { + continue; + } + } + } + + public int $ipsum { + get { + foreach ([1, 2, 3] as $val) { + function (): void { + break; + }; + } + } + } + +} + +class ValidUsages +{ + + public int $i { + set (int $foo) { + switch ($foo) { + case 1: + break; + default: + break; + } + + foreach ([1, 2, 3] as $val) { + if (rand(0, 1)) { + break; + } else { + continue; + } + } + + for ($i = 0; $i < 5; $i++) { + if (rand(0, 1)) { + break; + } else { + continue; + } + } + + while (true) { + if (rand(0, 1)) { + break; + } else { + continue; + } + } + + do { + if (rand(0, 1)) { + break; + } else { + continue; + } + } while (true); + } + } + + public int $j { + set (int $foo) { + foreach ([1, 2, 3] as $val) { + switch ($foo) { + case 1: + break 2; + default: + break 2; + } + } + } + } + +} From 278cc9e0140dc0c41bbbec7464eb9b778d3716e1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 20 Dec 2024 10:43:04 +0100 Subject: [PATCH 27/41] PropertyHookAttributesRule - level 0 --- conf/config.level0.neon | 1 + .../Properties/PropertyHookAttributesRule.php | 37 +++++++++++ .../PropertyHookAttributesRuleTest.php | 62 +++++++++++++++++++ .../data/property-hook-attributes.php | 57 +++++++++++++++++ 4 files changed, 157 insertions(+) create mode 100644 src/Rules/Properties/PropertyHookAttributesRule.php create mode 100644 tests/PHPStan/Rules/Properties/PropertyHookAttributesRuleTest.php create mode 100644 tests/PHPStan/Rules/Properties/data/property-hook-attributes.php diff --git a/conf/config.level0.neon b/conf/config.level0.neon index c84cf8f5f7..1a46352922 100644 --- a/conf/config.level0.neon +++ b/conf/config.level0.neon @@ -97,6 +97,7 @@ rules: - PHPStan\Rules\Properties\MissingReadOnlyByPhpDocPropertyAssignRule - PHPStan\Rules\Properties\PropertiesInInterfaceRule - PHPStan\Rules\Properties\PropertyAttributesRule + - PHPStan\Rules\Properties\PropertyHookAttributesRule - PHPStan\Rules\Properties\PropertyInClassRule - PHPStan\Rules\Properties\ReadOnlyPropertyRule - PHPStan\Rules\Properties\ReadOnlyByPhpDocPropertyRule diff --git a/src/Rules/Properties/PropertyHookAttributesRule.php b/src/Rules/Properties/PropertyHookAttributesRule.php new file mode 100644 index 0000000000..bd4968e8bf --- /dev/null +++ b/src/Rules/Properties/PropertyHookAttributesRule.php @@ -0,0 +1,37 @@ + + */ +final class PropertyHookAttributesRule implements Rule +{ + + public function __construct(private AttributesCheck $attributesCheck) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->attributesCheck->check( + $scope, + $node->getOriginalNode()->attrGroups, + Attribute::TARGET_METHOD, + 'method', + ); + } + +} diff --git a/tests/PHPStan/Rules/Properties/PropertyHookAttributesRuleTest.php b/tests/PHPStan/Rules/Properties/PropertyHookAttributesRuleTest.php new file mode 100644 index 0000000000..5f627c1902 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/PropertyHookAttributesRuleTest.php @@ -0,0 +1,62 @@ + + */ +class PropertyHookAttributesRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = $this->createReflectionProvider(); + return new PropertyHookAttributesRule( + new AttributesCheck( + $reflectionProvider, + new FunctionCallParametersCheck( + new RuleLevelHelper($reflectionProvider, true, false, true, false, false, false), + new NullsafeCheck(), + new UnresolvableTypeHelper(), + new PropertyReflectionFinder(), + true, + true, + true, + true, + ), + new ClassNameCheck( + new ClassCaseSensitivityCheck($reflectionProvider, false), + new ClassForbiddenNameCheck(self::getContainer()), + ), + true, + ), + ); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/property-hook-attributes.php'], [ + [ + 'Attribute class PropertyHookAttributes\Foo does not have the method target.', + 27, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/property-hook-attributes.php b/tests/PHPStan/Rules/Properties/data/property-hook-attributes.php new file mode 100644 index 0000000000..495cc793b0 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/property-hook-attributes.php @@ -0,0 +1,57 @@ += 8.4 + +namespace PropertyHookAttributes; + +#[\Attribute(\Attribute::TARGET_CLASS)] +class Foo +{ + +} + +#[\Attribute(\Attribute::TARGET_METHOD)] +class Bar +{ + +} + +#[\Attribute(\Attribute::TARGET_ALL)] +class Baz +{ + +} + +class Lorem +{ + + public int $i { + #[Foo] + get { + + } + } + +} + +class Ipsum +{ + + public int $i { + #[Bar] + get { + + } + } + +} + +class Dolor +{ + + public int $i { + #[Baz] + get { + + } + } + +} From b81fc37749d155510e4be46f881f6c9b04f6708a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 20 Dec 2024 11:24:21 +0100 Subject: [PATCH 28/41] Hooked properties can throw custom exceptions --- src/Analyser/NodeScopeResolver.php | 100 +++++++++ .../AbilityToDisableImplicitThrowsTest.php | 39 ++++ .../CatchWithUnthrownExceptionRuleTest.php | 42 ++++ .../Rules/Exceptions/data/bug-5903.php | 2 +- .../Rules/Exceptions/data/bug-6791.php | 2 +- .../Exceptions/data/union-type-error.php | 2 +- ...roperty-hooks-implicit-throws-disabled.php | 120 +++++++++++ .../unthrown-exception-property-hooks.php | 200 ++++++++++++++++++ 8 files changed, 504 insertions(+), 3 deletions(-) create mode 100644 tests/PHPStan/Rules/Exceptions/data/unthrown-exception-property-hooks-implicit-throws-disabled.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/unthrown-exception-property-hooks.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3bc323341c..7339f0a042 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -151,6 +151,7 @@ use PHPStan\Reflection\Php\PhpFunctionFromParserNodeReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Reflection\Php\PhpMethodReflection; +use PHPStan\Reflection\Php\PhpPropertyReflection; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Reflection\SignatureMap\SignatureMapProvider; use PHPStan\Rules\Properties\ReadWritePropertiesExtensionProvider; @@ -2973,6 +2974,7 @@ static function (): void { $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); } elseif ($expr instanceof PropertyFetch) { + $scopeBeforeVar = $scope; $result = $this->processExprNode($stmt, $expr->var, $scope, $nodeCallback, $context->enterDeep()); $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); @@ -2984,6 +2986,20 @@ static function (): void { $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); $scope = $result->getScope(); + if ($this->phpVersion->supportsPropertyHooks()) { + $throwPoints[] = ThrowPoint::createImplicit($scope, $expr); + } + } else { + $propertyName = $expr->name->toString(); + $propertyHolderType = $scopeBeforeVar->getType($expr->var); + $propertyReflection = $scopeBeforeVar->getPropertyReflection($propertyHolderType, $propertyName); + if ($propertyReflection !== null) { + $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); + if ($propertyDeclaringClass->hasNativeProperty($propertyName)) { + $nativeProperty = $propertyDeclaringClass->getNativeProperty($propertyName); + $throwPoints = array_merge($throwPoints, $this->getPropertyReadThrowPointsFromGetHook($scopeBeforeVar, $expr, $nativeProperty)); + } + } } } elseif ($expr instanceof Expr\NullsafePropertyFetch) { $nonNullabilityResult = $this->ensureShallowNonNullability($scope, $scope, $expr->var); @@ -4224,6 +4240,83 @@ private function getStaticMethodThrowPoint(MethodReflection $methodReflection, P return null; } + /** + * @return ThrowPoint[] + */ + private function getPropertyReadThrowPointsFromGetHook( + MutatingScope $scope, + PropertyFetch $propertyFetch, + PhpPropertyReflection $propertyReflection, + ): array + { + return $this->getThrowPointsFromPropertyHook($scope, $propertyFetch, $propertyReflection, 'get'); + } + + /** + * @return ThrowPoint[] + */ + private function getPropertyAssignThrowPointsFromSetHook( + MutatingScope $scope, + PropertyFetch $propertyFetch, + PhpPropertyReflection $propertyReflection, + ): array + { + return $this->getThrowPointsFromPropertyHook($scope, $propertyFetch, $propertyReflection, 'set'); + } + + /** + * @param 'get'|'set' $hookName + * @return ThrowPoint[] + */ + private function getThrowPointsFromPropertyHook( + MutatingScope $scope, + PropertyFetch $propertyFetch, + PhpPropertyReflection $propertyReflection, + string $hookName, + ): array + { + $scopeFunction = $scope->getFunction(); + if ( + $scopeFunction instanceof PhpMethodFromParserNodeReflection + && $scopeFunction->isPropertyHook() + && $propertyFetch->var instanceof Variable + && $propertyFetch->var->name === 'this' + && $propertyFetch->name instanceof Identifier + && $propertyFetch->name->toString() === $scopeFunction->getHookedPropertyName() + ) { + return []; + } + $declaringClass = $propertyReflection->getDeclaringClass(); + if (!$propertyReflection->hasHook($hookName)) { + if ( + $propertyReflection->isPrivate() + || $propertyReflection->isFinal()->yes() + || $declaringClass->isFinal() + ) { + return []; + } + + if ($this->implicitThrows) { + return [ThrowPoint::createImplicit($scope, $propertyFetch)]; + } + + return []; + } + + $getHook = $propertyReflection->getHook($hookName); + $throwType = $getHook->getThrowType(); + + if ($throwType !== null) { + if (!$throwType->isVoid()->yes()) { + return [ThrowPoint::createExplicit($scope, $throwType, $propertyFetch, true)]; + } + } elseif ($this->implicitThrows) { + return [ThrowPoint::createImplicit($scope, $propertyFetch)]; + } + + return []; + } + /** * @return string[] */ @@ -5408,6 +5501,10 @@ static function (): void { $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); $scope = $result->getScope(); + if ($var->name instanceof Expr && $this->phpVersion->supportsPropertyHooks()) { + $throwPoints[] = ThrowPoint::createImplicit($scope, $var); + } + $propertyHolderType = $scope->getType($var->var); if ($propertyName !== null && $propertyHolderType->hasProperty($propertyName)->yes()) { $propertyReflection = $propertyHolderType->getProperty($propertyName, $scope); @@ -5424,6 +5521,9 @@ static function (): void { ) { $throwPoints[] = ThrowPoint::createExplicit($scope, new ObjectType(TypeError::class), $assignedExpr, false); } + if ($this->phpVersion->supportsPropertyHooks()) { + $throwPoints = array_merge($throwPoints, $this->getPropertyAssignThrowPointsFromSetHook($scope, $var, $nativeProperty)); + } if ($enterExpressionAssign) { $scope = $scope->assignInitializedProperty($propertyHolderType, $propertyName); } diff --git a/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php b/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php index 08f4885067..33a117fd17 100644 --- a/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php +++ b/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php @@ -5,6 +5,7 @@ use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use function array_merge; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -33,6 +34,44 @@ public function testRule(): void ]); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/unthrown-exception-property-hooks-implicit-throws-disabled.php'], [ + [ + 'Dead catch - UnthrownExceptionPropertyHooksImplicitThrowsDisabled\MyCustomException is never thrown in the try block.', + 23, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooksImplicitThrowsDisabled\MyCustomException is never thrown in the try block.', + 38, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooksImplicitThrowsDisabled\MyCustomException is never thrown in the try block.', + 53, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooksImplicitThrowsDisabled\MyCustomException is never thrown in the try block.', + 68, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooksImplicitThrowsDisabled\MyCustomException is never thrown in the try block.', + 74, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooksImplicitThrowsDisabled\MyCustomException is never thrown in the try block.', + 94, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooksImplicitThrowsDisabled\MyCustomException is never thrown in the try block.', + 115, + ], + ]); + } + public static function getAdditionalConfigFiles(): array { return array_merge( diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 834bac42ff..6eacf1535d 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -612,4 +612,46 @@ public function testBug9568(): void $this->analyse([__DIR__ . '/data/bug-9568.php'], []); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/unthrown-exception-property-hooks.php'], [ + [ + 'Dead catch - UnthrownExceptionPropertyHooks\MyCustomException is never thrown in the try block.', + 27, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooks\SomeException is never thrown in the try block.', + 39, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooks\MyCustomException is never thrown in the try block.', + 53, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooks\SomeException is never thrown in the try block.', + 65, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooks\MyCustomException is never thrown in the try block.', + 107, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooks\MyCustomException is never thrown in the try block.', + 128, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooks\MyCustomException is never thrown in the try block.', + 154, + ], + [ + 'Dead catch - UnthrownExceptionPropertyHooks\MyCustomException is never thrown in the try block.', + 175, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-5903.php b/tests/PHPStan/Rules/Exceptions/data/bug-5903.php index b4c12e3877..0300b6ecc8 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-5903.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-5903.php @@ -2,7 +2,7 @@ namespace Bug5903; -class Test +final class Test { /** @var \Traversable */ protected $traversable; diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-6791.php b/tests/PHPStan/Rules/Exceptions/data/bug-6791.php index 73b9f59106..300aad76b2 100644 --- a/tests/PHPStan/Rules/Exceptions/data/bug-6791.php +++ b/tests/PHPStan/Rules/Exceptions/data/bug-6791.php @@ -2,7 +2,7 @@ namespace Bug6791; -class Foo { +final class Foo { /** @var int[] */ public array $intArray; /** @var \Ds\Set */ diff --git a/tests/PHPStan/Rules/Exceptions/data/union-type-error.php b/tests/PHPStan/Rules/Exceptions/data/union-type-error.php index 1c16fec53d..cad8c5348c 100644 --- a/tests/PHPStan/Rules/Exceptions/data/union-type-error.php +++ b/tests/PHPStan/Rules/Exceptions/data/union-type-error.php @@ -4,7 +4,7 @@ namespace UnionTypeError; -class Foo { +final class Foo { public string|int $stringOrInt; public string|array $stringOrArray; diff --git a/tests/PHPStan/Rules/Exceptions/data/unthrown-exception-property-hooks-implicit-throws-disabled.php b/tests/PHPStan/Rules/Exceptions/data/unthrown-exception-property-hooks-implicit-throws-disabled.php new file mode 100644 index 0000000000..6d47003630 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/unthrown-exception-property-hooks-implicit-throws-disabled.php @@ -0,0 +1,120 @@ += 8.4 + +namespace UnthrownExceptionPropertyHooksImplicitThrowsDisabled; + +class MyCustomException extends \Exception +{ + +} + +class SomeException extends \Exception +{ + +} + +class Foo +{ + public int $i; + + public function doFoo(): void + { + try { + echo $this->i; + } catch (MyCustomException) { // unthrown - implicit @throws disabled + + } + } + + public int $k { + get { + return 1; + } + } + + public function doBaz(): void + { + try { + echo $this->k; + } catch (MyCustomException) { // unthrown - implicit @throws disabled + + } + } + + private int $l { + get { + return $this->l; + } + } + + public function doLorem(): void + { + try { + echo $this->l; + } catch (MyCustomException) { // unthrown - implicit @throws disabled + + } + } + + final public int $m { + get { + return $this->m; + } + } + + public function doIpsum(): void + { + try { + echo $this->m; + } catch (MyCustomException) { // unthrown - implicit @throws disabled + + } + + try { + $this->m = 1; + } catch (MyCustomException) { // unthrown - set hook does not exist + + } + } + +} + +final class FinalFoo +{ + + public int $m { + get { + return $this->m; + } + } + + public function doIpsum(): void + { + try { + echo $this->m; + } catch (MyCustomException) { // unthrown - implicit @throws disabled + + } + } + +} + +class ThrowsVoid +{ + + public int $m { + /** @throws void */ + get { + return $this->m; + } + } + + public function doIpsum(): void + { + try { + echo $this->m; + } catch (MyCustomException) { // unthrown + + } + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/data/unthrown-exception-property-hooks.php b/tests/PHPStan/Rules/Exceptions/data/unthrown-exception-property-hooks.php new file mode 100644 index 0000000000..547a47eece --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/unthrown-exception-property-hooks.php @@ -0,0 +1,200 @@ += 8.4 + +namespace UnthrownExceptionPropertyHooks; + +class MyCustomException extends \Exception +{ + +} + +class SomeException extends \Exception +{ + +} + +class Foo +{ + + public int $i { + /** @throws MyCustomException */ + get { + if (rand(0, 1)) { + throw new MyCustomException(); + } + + try { + return $this->i; + } catch (MyCustomException) { // unthrown - @throws does not apply to direct access in the hook + + } + } + } + + public function doFoo(): void + { + try { + $a = $this->i; + } catch (MyCustomException) { + + } catch (SomeException) { // unthrown + + } + } + + public int $j { + /** @throws MyCustomException */ + set { + if (rand(0, 1)) { + throw new MyCustomException(); + } + + try { + $this->j = $value; + } catch (MyCustomException) { // unthrown - @throws does not apply to direct access in the hook + + } + } + } + + public function doBar(int $v): void + { + try { + $this->j = $v; + } catch (MyCustomException) { + + } catch (SomeException) { // unthrown + + } + } + + public int $k { + get { + return 1; + } + } + + public function doBaz(): void + { + try { + echo $this->k; + } catch (MyCustomException) { // can be thrown - implicit @throws + + } + + try { + $this->k = 1; + } catch (MyCustomException) { // can be thrown - subclass might introduce a set hook + + } + } + + private int $l { + get { + return $this->l; + } + } + + public function doLorem(): void + { + try { + echo $this->l; + } catch (MyCustomException) { // can be thrown - implicit @throws + + } + + try { + $this->l = 1; + } catch (MyCustomException) { // unthrown - set hook does not exist + + } + } + + final public int $m { + get { + return $this->m; + } + } + + public function doIpsum(): void + { + try { + echo $this->m; + } catch (MyCustomException) { // can be thrown - implicit @throws + + } + + try { + $this->m = 1; + } catch (MyCustomException) { // unthrown - set hook does not exist + + } + } + +} + +final class FinalFoo +{ + + public int $m { + get { + return $this->m; + } + } + + public function doIpsum(): void + { + try { + echo $this->m; + } catch (MyCustomException) { // can be thrown - implicit @throws + + } + + try { + $this->m = 1; + } catch (MyCustomException) { // unthrown - set hook does not exist + + } + } + +} + +class ThrowsVoid +{ + + public int $m { + /** @throws void */ + get { + return $this->m; + } + } + + public function doIpsum(): void + { + try { + echo $this->m; + } catch (MyCustomException) { // unthrown + + } + } + +} + +class Dynamic +{ + + public function doFoo(object $o, string $s): void + { + try { + echo $o->$s; + } catch (MyCustomException) { // implicit throw point + + } + + try { + $o->$s = 1; + } catch (MyCustomException) { // implicit throw point + + } + } + +} From d8585438b9edf03256f8429b9ad36ed1074bf521 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 20 Dec 2024 13:58:29 +0100 Subject: [PATCH 29/41] TooWidePropertyHookThrowTypeRule - level 4 --- conf/config.level4.neon | 5 ++ .../TooWidePropertyHookThrowTypeRule.php | 74 +++++++++++++++++ .../TooWidePropertyHookThrowTypeRuleTest.php | 49 +++++++++++ .../data/too-wide-throws-property-hook.php | 81 +++++++++++++++++++ 4 files changed, 209 insertions(+) create mode 100644 src/Rules/Exceptions/TooWidePropertyHookThrowTypeRule.php create mode 100644 tests/PHPStan/Rules/Exceptions/TooWidePropertyHookThrowTypeRuleTest.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/too-wide-throws-property-hook.php diff --git a/conf/config.level4.neon b/conf/config.level4.neon index bda46632c2..b026238cfb 100644 --- a/conf/config.level4.neon +++ b/conf/config.level4.neon @@ -31,6 +31,8 @@ conditionalTags: phpstan.rules.rule: %exceptions.check.tooWideThrowType% PHPStan\Rules\Exceptions\TooWideMethodThrowTypeRule: phpstan.rules.rule: %exceptions.check.tooWideThrowType% + PHPStan\Rules\Exceptions\TooWidePropertyHookThrowTypeRule: + phpstan.rules.rule: %exceptions.check.tooWideThrowType% parameters: checkAdvancedIsset: true @@ -241,6 +243,9 @@ services: - class: PHPStan\Rules\Exceptions\TooWideMethodThrowTypeRule + - + class: PHPStan\Rules\Exceptions\TooWidePropertyHookThrowTypeRule + - class: PHPStan\Rules\TooWideTypehints\TooWideMethodReturnTypehintRule arguments: diff --git a/src/Rules/Exceptions/TooWidePropertyHookThrowTypeRule.php b/src/Rules/Exceptions/TooWidePropertyHookThrowTypeRule.php new file mode 100644 index 0000000000..00ed4bacd4 --- /dev/null +++ b/src/Rules/Exceptions/TooWidePropertyHookThrowTypeRule.php @@ -0,0 +1,74 @@ + + */ +final class TooWidePropertyHookThrowTypeRule implements Rule +{ + + public function __construct(private FileTypeMapper $fileTypeMapper, private TooWideThrowTypeCheck $check) + { + } + + public function getNodeType(): string + { + return PropertyHookReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $statementResult = $node->getStatementResult(); + $hookReflection = $node->getHookReflection(); + if ($hookReflection->getPropertyHookName() === null) { + throw new ShouldNotHappenException(); + } + + $classReflection = $node->getClassReflection(); + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $classReflection->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $hookReflection->getName(), + $docComment->getText(), + ); + + if ($resolvedPhpDoc->getThrowsTag() === null) { + return []; + } + + $throwType = $resolvedPhpDoc->getThrowsTag()->getType(); + + $errors = []; + foreach ($this->check->check($throwType, $statementResult->getThrowPoints()) as $throwClass) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s hook for property %s::$%s has %s in PHPDoc @throws tag but it\'s not thrown.', + ucfirst($hookReflection->getPropertyHookName()), + $hookReflection->getDeclaringClass()->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $throwClass, + )) + ->identifier('throws.unusedType') + ->build(); + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/TooWidePropertyHookThrowTypeRuleTest.php b/tests/PHPStan/Rules/Exceptions/TooWidePropertyHookThrowTypeRuleTest.php new file mode 100644 index 0000000000..0c3d0f75a1 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/TooWidePropertyHookThrowTypeRuleTest.php @@ -0,0 +1,49 @@ + + */ +class TooWidePropertyHookThrowTypeRuleTest extends RuleTestCase +{ + + private bool $implicitThrows = true; + + protected function getRule(): Rule + { + return new TooWidePropertyHookThrowTypeRule(self::getContainer()->getByType(FileTypeMapper::class), new TooWideThrowTypeCheck($this->implicitThrows)); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/too-wide-throws-property-hook.php'], [ + [ + 'Get hook for property TooWideThrowsPropertyHook\Foo::$d has DomainException in PHPDoc @throws tag but it\'s not thrown.', + 33, + ], + [ + 'Get hook for property TooWideThrowsPropertyHook\Foo::$g has DomainException in PHPDoc @throws tag but it\'s not thrown.', + 58, + ], + [ + 'Get hook for property TooWideThrowsPropertyHook\Foo::$h has DomainException in PHPDoc @throws tag but it\'s not thrown.', + 68, + ], + [ + 'Get hook for property TooWideThrowsPropertyHook\Foo::$j has DomainException in PHPDoc @throws tag but it\'s not thrown.', + 76, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/data/too-wide-throws-property-hook.php b/tests/PHPStan/Rules/Exceptions/data/too-wide-throws-property-hook.php new file mode 100644 index 0000000000..92998bafa4 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/too-wide-throws-property-hook.php @@ -0,0 +1,81 @@ += 8.4 + +namespace TooWideThrowsPropertyHook; + +use DomainException; + +class Foo +{ + + public int $a { + /** @throws \InvalidArgumentException */ + get { + throw new \InvalidArgumentException(); + } + } + + public int $b { + /** @throws \LogicException */ + get { + throw new \InvalidArgumentException(); + } + } + + public int $c { + /** @throws \InvalidArgumentException */ + get { + throw new \LogicException(); + } + } + + public int $d { + /** @throws \InvalidArgumentException|\DomainException */ + get { // error - DomainException unused + throw new \InvalidArgumentException(); + } + } + + public int $e { + /** @throws void */ + get { // ok - picked up by different rule + throw new \InvalidArgumentException(); + } + } + + public int $f { + /** @throws \InvalidArgumentException|\DomainException */ + get { + if (rand(0, 1)) { + throw new \InvalidArgumentException(); + } + + throw new DomainException(); + } + } + + public int $g { + /** @throws \DomainException */ + get { // error - DomainException unused + throw new \InvalidArgumentException(); + } + } + + public int $h { + /** + * @throws \InvalidArgumentException + * @throws \DomainException + */ + get { // error - DomainException unused + throw new \InvalidArgumentException(); + } + } + + + public int $j { + /** @throws \DomainException */ + get { // error - DomainException unused + + } + } + +} From ca79b6e2d238c9837f1ca3b1034a902e51fd35ce Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 20 Dec 2024 14:35:01 +0100 Subject: [PATCH 30/41] ThrowsVoidPropertyHookWithExplicitThrowPointRule - level 3 --- conf/config.level3.neon | 8 ++ ...PropertyHookWithExplicitThrowPointRule.php | 79 ++++++++++++++ ...ertyHookWithExplicitThrowPointRuleTest.php | 103 ++++++++++++++++++ .../data/throws-void-property-hook.php | 22 ++++ 4 files changed, 212 insertions(+) create mode 100644 src/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRule.php create mode 100644 tests/PHPStan/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRuleTest.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/throws-void-property-hook.php diff --git a/conf/config.level3.neon b/conf/config.level3.neon index b7d1a4c15e..c946a5ee3f 100644 --- a/conf/config.level3.neon +++ b/conf/config.level3.neon @@ -69,6 +69,14 @@ services: tags: - phpstan.rules.rule + - + class: PHPStan\Rules\Exceptions\ThrowsVoidPropertyHookWithExplicitThrowPointRule + arguments: + exceptionTypeResolver: @exceptionTypeResolver + missingCheckedExceptionInThrows: %exceptions.check.missingCheckedExceptionInThrows% + tags: + - phpstan.rules.rule + - class: PHPStan\Rules\Generators\YieldFromTypeRule arguments: diff --git a/src/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRule.php b/src/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRule.php new file mode 100644 index 0000000000..71b7cd9c2d --- /dev/null +++ b/src/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRule.php @@ -0,0 +1,79 @@ + + */ +final class ThrowsVoidPropertyHookWithExplicitThrowPointRule implements Rule +{ + + public function __construct( + private ExceptionTypeResolver $exceptionTypeResolver, + private bool $missingCheckedExceptionInThrows, + ) + { + } + + public function getNodeType(): string + { + return PropertyHookReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $hookReflection = $node->getHookReflection(); + + if ($hookReflection->getThrowType() === null || !$hookReflection->getThrowType()->isVoid()->yes()) { + return []; + } + + if ($hookReflection->getPropertyHookName() === null) { + throw new ShouldNotHappenException(); + } + + $errors = []; + foreach ($statementResult->getThrowPoints() as $throwPoint) { + if (!$throwPoint->isExplicit()) { + continue; + } + + foreach (TypeUtils::flattenTypes($throwPoint->getType()) as $throwPointType) { + $isCheckedException = TrinaryLogic::createFromBoolean($this->missingCheckedExceptionInThrows)->lazyAnd( + $throwPointType->getObjectClassNames(), + fn (string $objectClassName) => TrinaryLogic::createFromBoolean($this->exceptionTypeResolver->isCheckedException($objectClassName, $throwPoint->getScope())), + ); + if ($isCheckedException->yes()) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + '%s hook for property %s::$%s throws exception %s but the PHPDoc contains @throws void.', + ucfirst($hookReflection->getPropertyHookName()), + $hookReflection->getDeclaringClass()->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $throwPointType->describe(VerbosityLevel::typeOnly()), + )) + ->line($throwPoint->getNode()->getStartLine()) + ->identifier('throws.void') + ->build(); + } + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRuleTest.php b/tests/PHPStan/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRuleTest.php new file mode 100644 index 0000000000..fecb9cfdc5 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/ThrowsVoidPropertyHookWithExplicitThrowPointRuleTest.php @@ -0,0 +1,103 @@ + + */ +class ThrowsVoidPropertyHookWithExplicitThrowPointRuleTest extends RuleTestCase +{ + + private bool $missingCheckedExceptionInThrows; + + /** @var string[] */ + private array $checkedExceptionClasses; + + protected function getRule(): Rule + { + return new ThrowsVoidPropertyHookWithExplicitThrowPointRule(new DefaultExceptionTypeResolver( + $this->createReflectionProvider(), + [], + [], + [], + $this->checkedExceptionClasses, + ), $this->missingCheckedExceptionInThrows); + } + + public function dataRule(): array + { + return [ + [ + true, + [], + [], + ], + [ + false, + ['DifferentException'], + [ + [ + 'Get hook for property ThrowsVoidPropertyHook\Foo::$i throws exception ThrowsVoidPropertyHook\MyException but the PHPDoc contains @throws void.', + 18, + ], + ], + ], + [ + true, + ['ThrowsVoidPropertyHook\\MyException'], + [], + ], + [ + true, + ['DifferentException'], + [ + [ + 'Get hook for property ThrowsVoidPropertyHook\Foo::$i throws exception ThrowsVoidPropertyHook\MyException but the PHPDoc contains @throws void.', + 18, + ], + ], + ], + [ + false, + [], + [ + [ + 'Get hook for property ThrowsVoidPropertyHook\Foo::$i throws exception ThrowsVoidPropertyHook\MyException but the PHPDoc contains @throws void.', + 18, + ], + ], + ], + [ + false, + ['ThrowsVoidPropertyHook\\MyException'], + [ + [ + 'Get hook for property ThrowsVoidPropertyHook\Foo::$i throws exception ThrowsVoidPropertyHook\MyException but the PHPDoc contains @throws void.', + 18, + ], + ], + ], + ]; + } + + /** + * @dataProvider dataRule + * @param string[] $checkedExceptionClasses + * @param list $errors + */ + public function testRule(bool $missingCheckedExceptionInThrows, array $checkedExceptionClasses, array $errors): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->missingCheckedExceptionInThrows = $missingCheckedExceptionInThrows; + $this->checkedExceptionClasses = $checkedExceptionClasses; + $this->analyse([__DIR__ . '/data/throws-void-property-hook.php'], $errors); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/data/throws-void-property-hook.php b/tests/PHPStan/Rules/Exceptions/data/throws-void-property-hook.php new file mode 100644 index 0000000000..82c4c2381f --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/throws-void-property-hook.php @@ -0,0 +1,22 @@ += 8.4 + +namespace ThrowsVoidPropertyHook; + +class MyException extends \Exception +{ + +} + +class Foo +{ + + public int $i { + /** + * @throws void + */ + get { + throw new MyException(); + } + } + +} From f9dad4d3a417b8eeb2cfe8808766fc3c5f11664b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 20 Dec 2024 14:50:33 +0100 Subject: [PATCH 31/41] MissingCheckedExceptionInPropertyHookThrowsRule --- conf/config.neon | 5 ++ ...eckedExceptionInPropertyHookThrowsRule.php | 55 +++++++++++++++++++ ...dExceptionInPropertyHookThrowsRuleTest.php | 51 +++++++++++++++++ ...missing-exception-property-hook-throws.php | 42 ++++++++++++++ 4 files changed, 153 insertions(+) create mode 100644 src/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRule.php create mode 100644 tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRuleTest.php create mode 100644 tests/PHPStan/Rules/Exceptions/data/missing-exception-property-hook-throws.php diff --git a/conf/config.neon b/conf/config.neon index 1501a7a253..ec1c876661 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -209,6 +209,8 @@ conditionalTags: phpstan.rules.rule: %exceptions.check.missingCheckedExceptionInThrows% PHPStan\Rules\Exceptions\MissingCheckedExceptionInMethodThrowsRule: phpstan.rules.rule: %exceptions.check.missingCheckedExceptionInThrows% + PHPStan\Rules\Exceptions\MissingCheckedExceptionInPropertyHookThrowsRule: + phpstan.rules.rule: %exceptions.check.missingCheckedExceptionInThrows% services: - @@ -906,6 +908,9 @@ services: - class: PHPStan\Rules\Exceptions\MissingCheckedExceptionInMethodThrowsRule + - + class: PHPStan\Rules\Exceptions\MissingCheckedExceptionInPropertyHookThrowsRule + - class: PHPStan\Rules\Exceptions\MissingCheckedExceptionInThrowsCheck arguments: diff --git a/src/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRule.php b/src/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRule.php new file mode 100644 index 0000000000..d9b7a6b864 --- /dev/null +++ b/src/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRule.php @@ -0,0 +1,55 @@ + + */ +final class MissingCheckedExceptionInPropertyHookThrowsRule implements Rule +{ + + public function __construct(private MissingCheckedExceptionInThrowsCheck $check) + { + } + + public function getNodeType(): string + { + return PropertyHookReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $statementResult = $node->getStatementResult(); + $hookReflection = $node->getHookReflection(); + + if (!$hookReflection->isPropertyHook()) { + throw new ShouldNotHappenException(); + } + + $errors = []; + foreach ($this->check->check($hookReflection->getThrowType(), $statementResult->getThrowPoints()) as [$className, $throwPointNode]) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s hook for property %s::$%s throws checked exception %s but it\'s missing from the PHPDoc @throws tag.', + ucfirst($hookReflection->getPropertyHookName()), + $hookReflection->getDeclaringClass()->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $className, + )) + ->line($throwPointNode->getStartLine()) + ->identifier('missingType.checkedException') + ->build(); + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRuleTest.php b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRuleTest.php new file mode 100644 index 0000000000..e7f6130d67 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/MissingCheckedExceptionInPropertyHookThrowsRuleTest.php @@ -0,0 +1,51 @@ + + */ +class MissingCheckedExceptionInPropertyHookThrowsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new MissingCheckedExceptionInPropertyHookThrowsRule( + new MissingCheckedExceptionInThrowsCheck(new DefaultExceptionTypeResolver( + $this->createReflectionProvider(), + [], + [ShouldNotHappenException::class], + [], + [], + )), + ); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/missing-exception-property-hook-throws.php'], [ + [ + 'Get hook for property MissingExceptionPropertyHookThrows\Foo::$k throws checked exception InvalidArgumentException but it\'s missing from the PHPDoc @throws tag.', + 25, + ], + [ + 'Set hook for property MissingExceptionPropertyHookThrows\Foo::$l throws checked exception InvalidArgumentException but it\'s missing from the PHPDoc @throws tag.', + 32, + ], + [ + 'Get hook for property MissingExceptionPropertyHookThrows\Foo::$m throws checked exception InvalidArgumentException but it\'s missing from the PHPDoc @throws tag.', + 38, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/data/missing-exception-property-hook-throws.php b/tests/PHPStan/Rules/Exceptions/data/missing-exception-property-hook-throws.php new file mode 100644 index 0000000000..d9fba8d0f1 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/missing-exception-property-hook-throws.php @@ -0,0 +1,42 @@ += 8.4 + +namespace MissingExceptionPropertyHookThrows; + +class Foo +{ + + public int $i { + /** @throws \InvalidArgumentException */ + get { + throw new \InvalidArgumentException(); // ok + } + } + + public int $j { + /** @throws \LogicException */ + set { + throw new \InvalidArgumentException(); // ok + } + } + + public int $k { + /** @throws \RuntimeException */ + get { + throw new \InvalidArgumentException(); // error + } + } + + public int $l { + /** @throws \RuntimeException */ + set { + throw new \InvalidArgumentException(); // error + } + } + + public int $m { + get { + throw new \InvalidArgumentException(); // error + } + } + +} From 24500399ae96bcb8229e9f811d5c2eb693bf5f66 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Dec 2024 13:51:30 +0100 Subject: [PATCH 32/41] Adjust InvalidThrowsPhpDocValueRule for property hooks --- .../PhpDoc/InvalidThrowsPhpDocValueRule.php | 14 ++++++++---- .../InvalidThrowsPhpDocValueRuleTest.php | 15 +++++++++++++ .../data/invalid-throws-property-hook.php | 22 +++++++++++++++++++ 3 files changed, 47 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Rules/PhpDoc/data/invalid-throws-property-hook.php diff --git a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php index 087c89b6ed..33a2e120c3 100644 --- a/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php +++ b/src/Rules/PhpDoc/InvalidThrowsPhpDocValueRule.php @@ -3,7 +3,9 @@ namespace PHPStan\Rules\PhpDoc; use PhpParser\Node; +use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; +use PHPStan\Node\InPropertyHookNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\FileTypeMapper; @@ -16,7 +18,7 @@ use function sprintf; /** - * @implements Rule + * @implements Rule */ final class InvalidThrowsPhpDocValueRule implements Rule { @@ -27,13 +29,17 @@ public function __construct(private FileTypeMapper $fileTypeMapper) public function getNodeType(): string { - return Node\Stmt::class; + return NodeAbstract::class; } public function processNode(Node $node, Scope $scope): array { - if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { - return []; // is handled by virtual nodes + if ($node instanceof Node\Stmt) { + if ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassMethod) { + return []; // is handled by virtual nodes + } + } elseif (!$node instanceof InPropertyHookNode) { + return []; } $docComment = $node->getDocComment(); diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php index 2328aeb0d7..e378f873b6 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidThrowsPhpDocValueRuleTest.php @@ -9,6 +9,7 @@ use PHPStan\Testing\RuleTestCase; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\VerbosityLevel; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -137,4 +138,18 @@ public function testMergeInheritedPhpDocs( $this->assertSame($expectedType, $throwsType->describe(VerbosityLevel::precise())); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/invalid-throws-property-hook.php'], [ + [ + 'PHPDoc tag @throws with type DateTimeImmutable is not subtype of Throwable', + 17, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/invalid-throws-property-hook.php b/tests/PHPStan/Rules/PhpDoc/data/invalid-throws-property-hook.php new file mode 100644 index 0000000000..c40b13aa9f --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/invalid-throws-property-hook.php @@ -0,0 +1,22 @@ += 8.4 + +namespace InvalidThrowsPropertyHook; + +class Foo +{ + + public int $i { + /** @throws \InvalidArgumentException */ + get { + return 1; + } + } + + public int $j { + /** @throws \DateTimeImmutable */ + get { + return 1; + } + } + +} From ffe82b35c78b520dcd90478948625d3190aaef25 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Dec 2024 14:05:32 +0100 Subject: [PATCH 33/41] Adjust InvalidPhpDocTagValueRule and InvalidPHPStanDocTagRule for property hooks --- src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php | 8 ++++++-- src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php | 8 ++++++-- .../Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php | 15 +++++++++++++++ .../PhpDoc/InvalidPhpDocTagValueRuleTest.php | 15 +++++++++++++++ .../PhpDoc/data/invalid-phpdoc-property-hooks.php | 15 +++++++++++++++ .../data/invalid-phpstan-tag-property-hooks.php | 15 +++++++++++++++ 6 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Rules/PhpDoc/data/invalid-phpdoc-property-hooks.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-tag-property-hooks.php diff --git a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php index 51b22dd564..c9e27ca74a 100644 --- a/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php +++ b/src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\PhpDoc; use PhpParser\Node; +use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; use PHPStan\Node\VirtualNode; use PHPStan\PhpDocParser\Lexer\Lexer; @@ -15,7 +16,7 @@ use function str_starts_with; /** - * @implements Rule + * @implements Rule */ final class InvalidPHPStanDocTagRule implements Rule { @@ -69,7 +70,7 @@ public function __construct( public function getNodeType(): string { - return Node\Stmt::class; + return NodeAbstract::class; } public function processNode(Node $node, Scope $scope): array @@ -78,6 +79,9 @@ public function processNode(Node $node, Scope $scope): array if ($node instanceof VirtualNode) { return []; } + if (!$node instanceof Node\Stmt && !$node instanceof Node\PropertyHook) { + return []; + } if ($node instanceof Node\Stmt\Expression) { if (!$node->expr instanceof Node\Expr\Assign && !$node->expr instanceof Node\Expr\AssignRef) { return []; diff --git a/src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php b/src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php index 2caa53394e..5e99af64f5 100644 --- a/src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php +++ b/src/Rules/PhpDoc/InvalidPhpDocTagValueRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\PhpDoc; use PhpParser\Node; +use PhpParser\NodeAbstract; use PHPStan\Analyser\Scope; use PHPStan\Node\VirtualNode; use PHPStan\PhpDocParser\Ast\PhpDoc\InvalidTagValueNode; @@ -17,7 +18,7 @@ use function str_starts_with; /** - * @implements Rule + * @implements Rule */ final class InvalidPhpDocTagValueRule implements Rule { @@ -31,7 +32,7 @@ public function __construct( public function getNodeType(): string { - return Node\Stmt::class; + return NodeAbstract::class; } public function processNode(Node $node, Scope $scope): array @@ -40,6 +41,9 @@ public function processNode(Node $node, Scope $scope): array if ($node instanceof VirtualNode) { return []; } + if (!$node instanceof Node\Stmt && !$node instanceof Node\PropertyHook) { + return []; + } if ($node instanceof Node\Stmt\Expression) { if (!$node->expr instanceof Node\Expr\Assign && !$node->expr instanceof Node\Expr\AssignRef) { return []; diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php index c664e1658a..e91e647054 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidPHPStanDocTagRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -52,4 +53,18 @@ public function testBug8697(): void $this->analyse([__DIR__ . '/data/bug-8697.php'], []); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/invalid-phpstan-tag-property-hooks.php'], [ + [ + 'Unknown PHPDoc tag: @phpstan-what', + 9, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/InvalidPhpDocTagValueRuleTest.php b/tests/PHPStan/Rules/PhpDoc/InvalidPhpDocTagValueRuleTest.php index 0047c107ed..be63bff8e2 100644 --- a/tests/PHPStan/Rules/PhpDoc/InvalidPhpDocTagValueRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/InvalidPhpDocTagValueRuleTest.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; +use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -144,4 +145,18 @@ public function testBug6692(): void ]); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/invalid-phpdoc-property-hooks.php'], [ + [ + 'PHPDoc tag @return has invalid value (Test(): Unexpected token "(", expected TOKEN_HORIZONTAL_WS at offset 16 on line 1', + 9, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/invalid-phpdoc-property-hooks.php b/tests/PHPStan/Rules/PhpDoc/data/invalid-phpdoc-property-hooks.php new file mode 100644 index 0000000000..f145c5d437 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/invalid-phpdoc-property-hooks.php @@ -0,0 +1,15 @@ += 8.4 + +namespace InvalidPhpDocPropertyHooks; + +class Foo +{ + + public int $i { + /** @return Test( */ + get { + + } + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-tag-property-hooks.php b/tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-tag-property-hooks.php new file mode 100644 index 0000000000..1221fe7b43 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/invalid-phpstan-tag-property-hooks.php @@ -0,0 +1,15 @@ += 8.4 + +namespace InvalidPHPStanTagPropertyHooks; + +class Foo +{ + + public int $i { + /** @phpstan-what what */ + get { + + } + } + +} From a57eea59733557efe940e5e01139b44455f2314c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Dec 2024 14:27:24 +0100 Subject: [PATCH 34/41] Test MatchExpressionRule with property hooks --- .../Comparison/MatchExpressionRuleTest.php | 14 ++++++++ .../data/match-expr-property-hooks.php | 33 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/match-expr-property-hooks.php diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index af0107c2a6..d7a005c589 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -503,4 +503,18 @@ public function testBug11852(): void $this->analyse([__DIR__ . '/data/bug-11852.php'], []); } + public function testPropertyHooks(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/match-expr-property-hooks.php'], [ + [ + 'Match expression does not handle remaining value: 3', + 13, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/match-expr-property-hooks.php b/tests/PHPStan/Rules/Comparison/data/match-expr-property-hooks.php new file mode 100644 index 0000000000..b59eb1dc3e --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/match-expr-property-hooks.php @@ -0,0 +1,33 @@ += 8.4 + +namespace MatchExprPropertyHooks; + +use UnhandledMatchError; + +class Foo +{ + + /** @var 1|2|3 */ + public int $i { + get { + return match ($this->i) { + 1 => 'foo', + 2 => 'bar', + }; + } + } + + /** + * @var 1|2|3 + */ + public int $j { + /** @throws UnhandledMatchError */ + get { + return match ($this->j) { + 1 => 10, + 2 => 20, + }; + } + } + +} From e28118e2f0051e3b463b2192a1f0fa036764956a Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Dec 2024 14:47:36 +0100 Subject: [PATCH 35/41] Extract IncompatiblePhpDocTypeCheck from IncompatiblePhpDocTypeRule --- conf/config.neon | 3 + src/PhpDoc/StubValidator.php | 3 +- .../PhpDoc/IncompatiblePhpDocTypeCheck.php | 236 ++++++++++++++++++ .../PhpDoc/IncompatiblePhpDocTypeRule.php | 215 +--------------- .../PhpDoc/IncompatiblePhpDocTypeRuleTest.php | 24 +- 5 files changed, 265 insertions(+), 216 deletions(-) create mode 100644 src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php diff --git a/conf/config.neon b/conf/config.neon index ec1c876661..4d7c3b4e99 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -1021,6 +1021,9 @@ services: - class: PHPStan\Rules\PhpDoc\GenericCallableRuleHelper + - + class: PHPStan\Rules\PhpDoc\IncompatiblePhpDocTypeCheck + - class: PHPStan\Rules\PhpDoc\VarTagTypeRuleHelper arguments: diff --git a/src/PhpDoc/StubValidator.php b/src/PhpDoc/StubValidator.php index 39ebcf09b3..33fde1e46e 100644 --- a/src/PhpDoc/StubValidator.php +++ b/src/PhpDoc/StubValidator.php @@ -80,6 +80,7 @@ use PHPStan\Rules\PhpDoc\GenericCallableRuleHelper; use PHPStan\Rules\PhpDoc\IncompatibleClassConstantPhpDocTypeRule; use PHPStan\Rules\PhpDoc\IncompatibleParamImmediatelyInvokedCallableRule; +use PHPStan\Rules\PhpDoc\IncompatiblePhpDocTypeCheck; use PHPStan\Rules\PhpDoc\IncompatiblePhpDocTypeRule; use PHPStan\Rules\PhpDoc\IncompatiblePropertyPhpDocTypeRule; use PHPStan\Rules\PhpDoc\IncompatibleSelfOutTypeRule; @@ -225,7 +226,7 @@ private function getRuleRegistry(Container $container): RuleRegistry new MethodTagTemplateTypeRule($methodTagTemplateTypeCheck), new MethodSignatureVarianceRule($varianceCheck), new TraitTemplateTypeRule($fileTypeMapper, $templateTypeCheck), - new IncompatiblePhpDocTypeRule($fileTypeMapper, $genericObjectTypeCheck, $unresolvableTypeHelper, $genericCallableRuleHelper), + new IncompatiblePhpDocTypeRule($fileTypeMapper, new IncompatiblePhpDocTypeCheck($genericObjectTypeCheck, $unresolvableTypeHelper, $genericCallableRuleHelper)), new IncompatiblePropertyPhpDocTypeRule($genericObjectTypeCheck, $unresolvableTypeHelper, $genericCallableRuleHelper), new InvalidPhpDocTagValueRule( $container->getByType(Lexer::class), diff --git a/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php b/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php new file mode 100644 index 0000000000..56c0ac529e --- /dev/null +++ b/src/Rules/PhpDoc/IncompatiblePhpDocTypeCheck.php @@ -0,0 +1,236 @@ + $nativeParameterTypes + * @param array $byRefParameters + * @return list + */ + public function check( + Scope $scope, + Node $node, + ResolvedPhpDocBlock $resolvedPhpDoc, + string $functionName, + array $nativeParameterTypes, + array $byRefParameters, + Type $nativeReturnType, + ): array + { + $errors = []; + + foreach (['@param' => $resolvedPhpDoc->getParamTags(), '@param-out' => $resolvedPhpDoc->getParamOutTags(), '@param-closure-this' => $resolvedPhpDoc->getParamClosureThisTags()] as $tagName => $parameters) { + foreach ($parameters as $parameterName => $phpDocParamTag) { + $phpDocParamType = $phpDocParamTag->getType(); + + if (!isset($nativeParameterTypes[$parameterName])) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s references unknown parameter: $%s', + $tagName, + $parameterName, + ))->identifier('parameter.notFound')->build(); + + } elseif ( + $this->unresolvableTypeHelper->containsUnresolvableType($phpDocParamType) + ) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for parameter $%s contains unresolvable type.', + $tagName, + $parameterName, + ))->identifier('parameter.unresolvableType')->build(); + + } else { + $nativeParamType = $nativeParameterTypes[$parameterName]; + if ( + $phpDocParamTag instanceof ParamTag + && $phpDocParamTag->isVariadic() + && $phpDocParamType->isArray()->yes() + && $nativeParamType->isArray()->no() + ) { + $phpDocParamType = $phpDocParamType->getIterableValueType(); + } + + $escapedParameterName = SprintfHelper::escapeFormatString($parameterName); + $escapedTagName = SprintfHelper::escapeFormatString($tagName); + + $errors = array_merge($errors, $this->genericObjectTypeCheck->check( + $phpDocParamType, + sprintf( + 'PHPDoc tag %s for parameter $%s contains generic type %%s but %%s %%s is not generic.', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Generic type %%s in PHPDoc tag %s for parameter $%s does not specify all template types of %%s %%s: %%s', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Generic type %%s in PHPDoc tag %s for parameter $%s specifies %%d template types, but %%s %%s supports only %%d: %%s', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Type %%s in generic type %%s in PHPDoc tag %s for parameter $%s is not subtype of template type %%s of %%s %%s.', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in PHPDoc tag %s for parameter $%s is in conflict with %%s template type %%s of %%s %%s.', + $escapedTagName, + $escapedParameterName, + ), + sprintf( + 'Call-site variance of %%s in generic type %%s in PHPDoc tag %s for parameter $%s is redundant, template type %%s of %%s %%s has the same variance.', + $escapedTagName, + $escapedParameterName, + ), + )); + + $errors = array_merge($errors, $this->genericCallableRuleHelper->check( + $node, + $scope, + sprintf('%s for parameter $%s', $escapedTagName, $escapedParameterName), + $phpDocParamType, + $functionName, + $resolvedPhpDoc->getTemplateTags(), + $scope->isInClass() ? $scope->getClassReflection() : null, + )); + + if ($phpDocParamTag instanceof ParamOutTag) { + if (!$byRefParameters[$parameterName]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter $%s for PHPDoc tag %s is not passed by reference.', + $parameterName, + $tagName, + ))->identifier('parameter.notByRef')->build(); + + } + continue; + } + + if (in_array($tagName, ['@param', '@param-out'], true)) { + $isParamSuperType = $nativeParamType->isSuperTypeOf($phpDocParamType); + if ($isParamSuperType->no()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for parameter $%s with type %s is incompatible with native type %s.', + $tagName, + $parameterName, + $phpDocParamType->describe(VerbosityLevel::typeOnly()), + $nativeParamType->describe(VerbosityLevel::typeOnly()), + ))->identifier('parameter.phpDocType')->build(); + + } elseif ($isParamSuperType->maybe()) { + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for parameter $%s with type %s is not subtype of native type %s.', + $tagName, + $parameterName, + $phpDocParamType->describe(VerbosityLevel::typeOnly()), + $nativeParamType->describe(VerbosityLevel::typeOnly()), + ))->identifier('parameter.phpDocType'); + if ($phpDocParamType instanceof TemplateType) { + $errorBuilder->tip(sprintf('Write @template %s of %s to fix this.', $phpDocParamType->getName(), $nativeParamType->describe(VerbosityLevel::typeOnly()))); + } + + $errors[] = $errorBuilder->build(); + } + } + + if ($tagName === '@param-closure-this') { + $isNonClosure = (new ClosureType())->isSuperTypeOf($nativeParamType)->no(); + if ($isNonClosure) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s is for parameter $%s with non-Closure type %s.', + $tagName, + $parameterName, + $nativeParamType->describe(VerbosityLevel::typeOnly()), + ))->identifier('paramClosureThis.nonClosure')->build(); + } + } + } + } + } + + if ($resolvedPhpDoc->getReturnTag() !== null) { + $phpDocReturnType = $resolvedPhpDoc->getReturnTag()->getType(); + + if ( + $this->unresolvableTypeHelper->containsUnresolvableType($phpDocReturnType) + ) { + $errors[] = RuleErrorBuilder::message('PHPDoc tag @return contains unresolvable type.')->identifier('return.unresolvableType')->build(); + + } else { + $isReturnSuperType = $nativeReturnType->isSuperTypeOf($phpDocReturnType); + $errors = array_merge($errors, $this->genericObjectTypeCheck->check( + $phpDocReturnType, + 'PHPDoc tag @return contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @return does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @return specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @return is not subtype of template type %s of %s %s.', + 'Call-site variance of %s in generic type %s in PHPDoc tag @return is in conflict with %s template type %s of %s %s.', + 'Call-site variance of %s in generic type %s in PHPDoc tag @return is redundant, template type %s of %s %s has the same variance.', + )); + if ($isReturnSuperType->no()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @return with type %s is incompatible with native type %s.', + $phpDocReturnType->describe(VerbosityLevel::typeOnly()), + $nativeReturnType->describe(VerbosityLevel::typeOnly()), + ))->identifier('return.phpDocType')->build(); + + } elseif ($isReturnSuperType->maybe()) { + $errorBuilder = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @return with type %s is not subtype of native type %s.', + $phpDocReturnType->describe(VerbosityLevel::typeOnly()), + $nativeReturnType->describe(VerbosityLevel::typeOnly()), + ))->identifier('return.phpDocType'); + if ($phpDocReturnType instanceof TemplateType) { + $errorBuilder->tip(sprintf('Write @template %s of %s to fix this.', $phpDocReturnType->getName(), $nativeReturnType->describe(VerbosityLevel::typeOnly()))); + } + + $errors[] = $errorBuilder->build(); + } + + $errors = array_merge($errors, $this->genericCallableRuleHelper->check( + $node, + $scope, + '@return', + $phpDocReturnType, + $functionName, + $resolvedPhpDoc->getTemplateTags(), + $scope->isInClass() ? $scope->getClassReflection() : null, + )); + } + } + + return $errors; + } + +} diff --git a/src/Rules/PhpDoc/IncompatiblePhpDocTypeRule.php b/src/Rules/PhpDoc/IncompatiblePhpDocTypeRule.php index acdbeef79f..47b3a78248 100644 --- a/src/Rules/PhpDoc/IncompatiblePhpDocTypeRule.php +++ b/src/Rules/PhpDoc/IncompatiblePhpDocTypeRule.php @@ -5,22 +5,11 @@ use PhpParser\Node; use PhpParser\Node\Expr\Variable; use PHPStan\Analyser\Scope; -use PHPStan\Internal\SprintfHelper; -use PHPStan\PhpDoc\Tag\ParamOutTag; -use PHPStan\PhpDoc\Tag\ParamTag; -use PHPStan\Rules\Generics\GenericObjectTypeCheck; use PHPStan\Rules\Rule; -use PHPStan\Rules\RuleErrorBuilder; use PHPStan\ShouldNotHappenException; -use PHPStan\Type\ClosureType; use PHPStan\Type\FileTypeMapper; -use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Type; -use PHPStan\Type\VerbosityLevel; -use function array_merge; -use function in_array; use function is_string; -use function sprintf; use function trim; /** @@ -31,9 +20,7 @@ final class IncompatiblePhpDocTypeRule implements Rule public function __construct( private FileTypeMapper $fileTypeMapper, - private GenericObjectTypeCheck $genericObjectTypeCheck, - private UnresolvableTypeHelper $unresolvableTypeHelper, - private GenericCallableRuleHelper $genericCallableRuleHelper, + private IncompatiblePhpDocTypeCheck $check, ) { } @@ -65,200 +52,20 @@ public function processNode(Node $node, Scope $scope): array $functionName, $docComment->getText(), ); - $nativeParameterTypes = $this->getNativeParameterTypes($node, $scope); - $byRefParameters = $this->getByRefParameters($node); - $errors = []; - - foreach (['@param' => $resolvedPhpDoc->getParamTags(), '@param-out' => $resolvedPhpDoc->getParamOutTags(), '@param-closure-this' => $resolvedPhpDoc->getParamClosureThisTags()] as $tagName => $parameters) { - foreach ($parameters as $parameterName => $phpDocParamTag) { - $phpDocParamType = $phpDocParamTag->getType(); - - if (!isset($nativeParameterTypes[$parameterName])) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag %s references unknown parameter: $%s', - $tagName, - $parameterName, - ))->identifier('parameter.notFound')->build(); - - } elseif ( - $this->unresolvableTypeHelper->containsUnresolvableType($phpDocParamType) - ) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag %s for parameter $%s contains unresolvable type.', - $tagName, - $parameterName, - ))->identifier('parameter.unresolvableType')->build(); - - } else { - $nativeParamType = $nativeParameterTypes[$parameterName]; - if ( - $phpDocParamTag instanceof ParamTag - && $phpDocParamTag->isVariadic() - && $phpDocParamType->isArray()->yes() - && $nativeParamType->isArray()->no() - ) { - $phpDocParamType = $phpDocParamType->getIterableValueType(); - } - - $escapedParameterName = SprintfHelper::escapeFormatString($parameterName); - $escapedTagName = SprintfHelper::escapeFormatString($tagName); - - $errors = array_merge($errors, $this->genericObjectTypeCheck->check( - $phpDocParamType, - sprintf( - 'PHPDoc tag %s for parameter $%s contains generic type %%s but %%s %%s is not generic.', - $escapedTagName, - $escapedParameterName, - ), - sprintf( - 'Generic type %%s in PHPDoc tag %s for parameter $%s does not specify all template types of %%s %%s: %%s', - $escapedTagName, - $escapedParameterName, - ), - sprintf( - 'Generic type %%s in PHPDoc tag %s for parameter $%s specifies %%d template types, but %%s %%s supports only %%d: %%s', - $escapedTagName, - $escapedParameterName, - ), - sprintf( - 'Type %%s in generic type %%s in PHPDoc tag %s for parameter $%s is not subtype of template type %%s of %%s %%s.', - $escapedTagName, - $escapedParameterName, - ), - sprintf( - 'Call-site variance of %%s in generic type %%s in PHPDoc tag %s for parameter $%s is in conflict with %%s template type %%s of %%s %%s.', - $escapedTagName, - $escapedParameterName, - ), - sprintf( - 'Call-site variance of %%s in generic type %%s in PHPDoc tag %s for parameter $%s is redundant, template type %%s of %%s %%s has the same variance.', - $escapedTagName, - $escapedParameterName, - ), - )); - - $errors = array_merge($errors, $this->genericCallableRuleHelper->check( - $node, - $scope, - sprintf('%s for parameter $%s', $escapedTagName, $escapedParameterName), - $phpDocParamType, - $functionName, - $resolvedPhpDoc->getTemplateTags(), - $scope->isInClass() ? $scope->getClassReflection() : null, - )); - - if ($phpDocParamTag instanceof ParamOutTag) { - if (!$byRefParameters[$parameterName]) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'Parameter $%s for PHPDoc tag %s is not passed by reference.', - $parameterName, - $tagName, - ))->identifier('parameter.notByRef')->build(); - - } - continue; - } - - if (in_array($tagName, ['@param', '@param-out'], true)) { - $isParamSuperType = $nativeParamType->isSuperTypeOf($phpDocParamType); - if ($isParamSuperType->no()) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag %s for parameter $%s with type %s is incompatible with native type %s.', - $tagName, - $parameterName, - $phpDocParamType->describe(VerbosityLevel::typeOnly()), - $nativeParamType->describe(VerbosityLevel::typeOnly()), - ))->identifier('parameter.phpDocType')->build(); - - } elseif ($isParamSuperType->maybe()) { - $errorBuilder = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag %s for parameter $%s with type %s is not subtype of native type %s.', - $tagName, - $parameterName, - $phpDocParamType->describe(VerbosityLevel::typeOnly()), - $nativeParamType->describe(VerbosityLevel::typeOnly()), - ))->identifier('parameter.phpDocType'); - if ($phpDocParamType instanceof TemplateType) { - $errorBuilder->tip(sprintf('Write @template %s of %s to fix this.', $phpDocParamType->getName(), $nativeParamType->describe(VerbosityLevel::typeOnly()))); - } - - $errors[] = $errorBuilder->build(); - } - } - - if ($tagName === '@param-closure-this') { - $isNonClosure = (new ClosureType())->isSuperTypeOf($nativeParamType)->no(); - if ($isNonClosure) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag %s is for parameter $%s with non-Closure type %s.', - $tagName, - $parameterName, - $nativeParamType->describe(VerbosityLevel::typeOnly()), - ))->identifier('paramClosureThis.nonClosure')->build(); - } - } - } - } - } - - if ($resolvedPhpDoc->getReturnTag() !== null) { - $phpDocReturnType = $resolvedPhpDoc->getReturnTag()->getType(); - - if ( - $this->unresolvableTypeHelper->containsUnresolvableType($phpDocReturnType) - ) { - $errors[] = RuleErrorBuilder::message('PHPDoc tag @return contains unresolvable type.')->identifier('return.unresolvableType')->build(); - - } else { - $nativeReturnType = $this->getNativeReturnType($node, $scope); - $isReturnSuperType = $nativeReturnType->isSuperTypeOf($phpDocReturnType); - $errors = array_merge($errors, $this->genericObjectTypeCheck->check( - $phpDocReturnType, - 'PHPDoc tag @return contains generic type %s but %s %s is not generic.', - 'Generic type %s in PHPDoc tag @return does not specify all template types of %s %s: %s', - 'Generic type %s in PHPDoc tag @return specifies %d template types, but %s %s supports only %d: %s', - 'Type %s in generic type %s in PHPDoc tag @return is not subtype of template type %s of %s %s.', - 'Call-site variance of %s in generic type %s in PHPDoc tag @return is in conflict with %s template type %s of %s %s.', - 'Call-site variance of %s in generic type %s in PHPDoc tag @return is redundant, template type %s of %s %s has the same variance.', - )); - if ($isReturnSuperType->no()) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag @return with type %s is incompatible with native type %s.', - $phpDocReturnType->describe(VerbosityLevel::typeOnly()), - $nativeReturnType->describe(VerbosityLevel::typeOnly()), - ))->identifier('return.phpDocType')->build(); - - } elseif ($isReturnSuperType->maybe()) { - $errorBuilder = RuleErrorBuilder::message(sprintf( - 'PHPDoc tag @return with type %s is not subtype of native type %s.', - $phpDocReturnType->describe(VerbosityLevel::typeOnly()), - $nativeReturnType->describe(VerbosityLevel::typeOnly()), - ))->identifier('return.phpDocType'); - if ($phpDocReturnType instanceof TemplateType) { - $errorBuilder->tip(sprintf('Write @template %s of %s to fix this.', $phpDocReturnType->getName(), $nativeReturnType->describe(VerbosityLevel::typeOnly()))); - } - - $errors[] = $errorBuilder->build(); - } - - $errors = array_merge($errors, $this->genericCallableRuleHelper->check( - $node, - $scope, - '@return', - $phpDocReturnType, - $functionName, - $resolvedPhpDoc->getTemplateTags(), - $scope->isInClass() ? $scope->getClassReflection() : null, - )); - } - } - - return $errors; + return $this->check->check( + $scope, + $node, + $resolvedPhpDoc, + $functionName, + $this->getNativeParameterTypes($node, $scope), + $this->getByRefParameters($node), + $this->getNativeReturnType($node, $scope), + ); } /** - * @return Type[] + * @return array */ private function getNativeParameterTypes(Node\FunctionLike $node, Scope $scope): array { diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php index ace29c0b95..9c9c5965ef 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php @@ -25,18 +25,20 @@ protected function getRule(): Rule return new IncompatiblePhpDocTypeRule( self::getContainer()->getByType(FileTypeMapper::class), - new GenericObjectTypeCheck(), - new UnresolvableTypeHelper(), - new GenericCallableRuleHelper( - new TemplateTypeCheck( - $reflectionProvider, - new ClassNameCheck( - new ClassCaseSensitivityCheck($reflectionProvider, true), - new ClassForbiddenNameCheck(self::getContainer()), + new IncompatiblePhpDocTypeCheck( + new GenericObjectTypeCheck(), + new UnresolvableTypeHelper(), + new GenericCallableRuleHelper( + new TemplateTypeCheck( + $reflectionProvider, + new ClassNameCheck( + new ClassCaseSensitivityCheck($reflectionProvider, true), + new ClassForbiddenNameCheck(self::getContainer()), + ), + new GenericObjectTypeCheck(), + $typeAliasResolver, + true, ), - new GenericObjectTypeCheck(), - $typeAliasResolver, - true, ), ), ); From 7f3c3405c9d0630f1e06dca7a6607b817ef23412 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Dec 2024 14:56:51 +0100 Subject: [PATCH 36/41] IncompatiblePropertyHookPhpDocTypeRule - level 2 --- conf/config.level2.neon | 1 + ...IncompatiblePropertyHookPhpDocTypeRule.php | 85 ++++++++++++++++++ ...mpatiblePropertyHookPhpDocTypeRuleTest.php | 89 +++++++++++++++++++ ...ncompatible-property-hook-phpdoc-types.php | 66 ++++++++++++++ 4 files changed, 241 insertions(+) create mode 100644 src/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRule.php create mode 100644 tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRuleTest.php create mode 100644 tests/PHPStan/Rules/PhpDoc/data/incompatible-property-hook-phpdoc-types.php diff --git a/conf/config.level2.neon b/conf/config.level2.neon index 2d547cb94e..9cd92e09e7 100644 --- a/conf/config.level2.neon +++ b/conf/config.level2.neon @@ -52,6 +52,7 @@ rules: - PHPStan\Rules\PhpDoc\IncompatibleSelfOutTypeRule - PHPStan\Rules\PhpDoc\IncompatibleClassConstantPhpDocTypeRule - PHPStan\Rules\PhpDoc\IncompatiblePhpDocTypeRule + - PHPStan\Rules\PhpDoc\IncompatiblePropertyHookPhpDocTypeRule - PHPStan\Rules\PhpDoc\IncompatiblePropertyPhpDocTypeRule - PHPStan\Rules\PhpDoc\InvalidThrowsPhpDocValueRule - PHPStan\Rules\PhpDoc\IncompatibleParamImmediatelyInvokedCallableRule diff --git a/src/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRule.php b/src/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRule.php new file mode 100644 index 0000000000..dffebfa1c8 --- /dev/null +++ b/src/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRule.php @@ -0,0 +1,85 @@ + + */ +final class IncompatiblePropertyHookPhpDocTypeRule implements Rule +{ + + public function __construct( + private FileTypeMapper $fileTypeMapper, + private IncompatiblePhpDocTypeCheck $check, + ) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $hookReflection = $node->getHookReflection(); + + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $node->getClassReflection()->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $hookReflection->getName(), + $docComment->getText(), + ); + + return $this->check->check( + $scope, + $node, + $resolvedPhpDoc, + $hookReflection->getName(), + $this->getNativeParameterTypes($hookReflection), + $this->getByRefParameters($hookReflection), + $hookReflection->getNativeReturnType(), + ); + } + + /** + * @return array + */ + private function getNativeParameterTypes(PhpMethodFromParserNodeReflection $node): array + { + $parameters = []; + foreach ($node->getParameters() as $parameter) { + $parameters[$parameter->getName()] = $parameter->getNativeType(); + } + + return $parameters; + } + + /** + * @return array + */ + private function getByRefParameters(PhpMethodFromParserNodeReflection $node): array + { + $parameters = []; + foreach ($node->getParameters() as $parameter) { + $parameters[$parameter->getName()] = false; + } + + return $parameters; + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRuleTest.php new file mode 100644 index 0000000000..b0d4d718ad --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePropertyHookPhpDocTypeRuleTest.php @@ -0,0 +1,89 @@ + + */ +class IncompatiblePropertyHookPhpDocTypeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = $this->createReflectionProvider(); + $typeAliasResolver = $this->createTypeAliasResolver([], $reflectionProvider); + + return new IncompatiblePropertyHookPhpDocTypeRule( + self::getContainer()->getByType(FileTypeMapper::class), + new IncompatiblePhpDocTypeCheck( + new GenericObjectTypeCheck(), + new UnresolvableTypeHelper(), + new GenericCallableRuleHelper( + new TemplateTypeCheck( + $reflectionProvider, + new ClassNameCheck( + new ClassCaseSensitivityCheck($reflectionProvider, true), + new ClassForbiddenNameCheck(self::getContainer()), + ), + new GenericObjectTypeCheck(), + $typeAliasResolver, + true, + ), + ), + ), + ); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/incompatible-property-hook-phpdoc-types.php'], [ + [ + 'PHPDoc tag @return with type string is incompatible with native type int.', + 10, + ], + [ + 'PHPDoc tag @return with type string is incompatible with native type void.', + 17, + ], + [ + 'PHPDoc tag @param for parameter $value with type string is incompatible with native type int.', + 27, + ], + [ + 'Parameter $value for PHPDoc tag @param-out is not passed by reference.', + 27, + ], + [ + 'PHPDoc tag @param for parameter $value contains unresolvable type.', + 34, + ], + [ + 'PHPDoc tag @param for parameter $value contains generic type Exception but class Exception is not generic.', + 41, + ], + [ + 'PHPDoc tag @param for parameter $value template T of callable(T): T shadows @template T for class IncompatiblePropertyHookPhpDocTypes\GenericFoo.', + 54, + ], + [ + 'PHPDoc tag @param for parameter $value template of callable<\stdClass of mixed>(T): T cannot have existing class \stdClass as its name.', + 61, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/PhpDoc/data/incompatible-property-hook-phpdoc-types.php b/tests/PHPStan/Rules/PhpDoc/data/incompatible-property-hook-phpdoc-types.php new file mode 100644 index 0000000000..b1ce3b8762 --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/incompatible-property-hook-phpdoc-types.php @@ -0,0 +1,66 @@ += 8.4 + +namespace IncompatiblePropertyHookPhpDocTypes; + +class Foo +{ + + public int $i { + /** @return string */ + get { + return $this->i; + } + } + + public int $j { + /** @return string */ + set { + $this->j = 1; + } + } + + public int $k { + /** + * @param string $value + * @param-out int $value + */ + set { + $this->k = 1; + } + } + + public int $l { + /** @param \stdClass&\Exception $value */ + set { + + } + } + + public \Exception $m { + /** @param \Exception $value */ + set { + + } + } + +} + +/** @template T */ +class GenericFoo +{ + + public int $n { + /** @param int|callable(T): T $value */ + set (int|callable $value) { + + } + } + + public int $o { + /** @param int|callable<\stdClass>(T): T $value */ + set (int|callable $value) { + + } + } + +} From 4030fc121656ff45181de24cfa66e358b8ff46d9 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 21 Dec 2024 15:34:29 +0100 Subject: [PATCH 37/41] ExistingClassesInPropertyHookTypehintsRule - level 0 --- Makefile | 1 + conf/config.level0.neon | 1 + src/Rules/FunctionDefinitionCheck.php | 2 +- ...tingClassesInPropertyHookTypehintsRule.php | 87 +++++++++++++++++++ ...ClassesInPropertyHookTypehintsRuleTest.php | 65 ++++++++++++++ .../data/existing-classes-property-hooks.php | 34 ++++++++ 6 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/Rules/Properties/ExistingClassesInPropertyHookTypehintsRule.php create mode 100644 tests/PHPStan/Rules/Properties/ExistingClassesInPropertyHookTypehintsRuleTest.php create mode 100644 tests/PHPStan/Rules/Properties/data/existing-classes-property-hooks.php diff --git a/Makefile b/Makefile index fc4a0fe42e..bc702f32d4 100644 --- a/Makefile +++ b/Makefile @@ -93,6 +93,7 @@ lint: --exclude tests/PHPStan/Rules/Classes/data/invalid-hooked-properties.php \ --exclude tests/PHPStan/Parser/data/cleaning-property-hooks-before.php \ --exclude tests/PHPStan/Parser/data/cleaning-property-hooks-after.php \ + --exclude tests/PHPStan/Rules/Properties/data/existing-classes-property-hooks.php \ src tests cs: diff --git a/conf/config.level0.neon b/conf/config.level0.neon index 1a46352922..ff1a67c728 100644 --- a/conf/config.level0.neon +++ b/conf/config.level0.neon @@ -92,6 +92,7 @@ rules: - PHPStan\Rules\Operators\InvalidIncDecOperationRule - PHPStan\Rules\Properties\AccessPropertiesInAssignRule - PHPStan\Rules\Properties\AccessStaticPropertiesInAssignRule + - PHPStan\Rules\Properties\ExistingClassesInPropertyHookTypehintsRule - PHPStan\Rules\Properties\InvalidCallablePropertyTypeRule - PHPStan\Rules\Properties\MissingReadOnlyPropertyAssignRule - PHPStan\Rules\Properties\MissingReadOnlyByPhpDocPropertyAssignRule diff --git a/src/Rules/FunctionDefinitionCheck.php b/src/Rules/FunctionDefinitionCheck.php index 6874582743..700f6e7b71 100644 --- a/src/Rules/FunctionDefinitionCheck.php +++ b/src/Rules/FunctionDefinitionCheck.php @@ -245,7 +245,7 @@ public function checkAnonymousFunction( */ public function checkClassMethod( PhpMethodFromParserNodeReflection $methodReflection, - ClassMethod $methodNode, + ClassMethod|Node\PropertyHook $methodNode, string $parameterMessage, string $returnMessage, string $unionTypesMessage, diff --git a/src/Rules/Properties/ExistingClassesInPropertyHookTypehintsRule.php b/src/Rules/Properties/ExistingClassesInPropertyHookTypehintsRule.php new file mode 100644 index 0000000000..be668710f6 --- /dev/null +++ b/src/Rules/Properties/ExistingClassesInPropertyHookTypehintsRule.php @@ -0,0 +1,87 @@ + + */ +final class ExistingClassesInPropertyHookTypehintsRule implements Rule +{ + + public function __construct(private FunctionDefinitionCheck $check) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $hookReflection = $node->getHookReflection(); + if (!$hookReflection->isPropertyHook()) { + throw new ShouldNotHappenException(); + } + $className = SprintfHelper::escapeFormatString($node->getClassReflection()->getDisplayName()); + $hookName = $hookReflection->getPropertyHookName(); + $propertyName = SprintfHelper::escapeFormatString($hookReflection->getHookedPropertyName()); + + $originalHookNode = $node->getOriginalNode(); + if ($hookReflection->getPropertyHookName() === 'set' && $originalHookNode->params === []) { + $originalHookNode = clone $originalHookNode; + $originalHookNode->params = [ + new Node\Param(new Variable('value'), null, null), + ]; + } + + return $this->check->checkClassMethod( + $hookReflection, + $originalHookNode, + sprintf( + 'Parameter $%%s of %s hook for property %s::$%s has invalid type %%s.', + $hookName, + $className, + $propertyName, + ), + sprintf( + '%s hook for property %s::$%s has invalid return type %%s.', + ucfirst($hookName), + $className, + $propertyName, + ), + sprintf('%s hook for property %s::$%s uses native union types but they\'re supported only on PHP 8.0 and later.', $hookName, $className, $propertyName), + sprintf('Template type %%s of %s hook for property %s::$%s is not referenced in a parameter.', $hookName, $className, $propertyName), + sprintf( + 'Parameter $%%s of %s hook for property %s::$%s has unresolvable native type.', + $hookName, + $className, + $propertyName, + ), + sprintf( + '%s hook for property %s::$%s has unresolvable native return type.', + ucfirst($hookName), + $className, + $propertyName, + ), + sprintf( + '%s hook for property %s::$%s has invalid @phpstan-self-out type %%s.', + ucfirst($hookName), + $className, + $propertyName, + ), + ); + } + +} diff --git a/tests/PHPStan/Rules/Properties/ExistingClassesInPropertyHookTypehintsRuleTest.php b/tests/PHPStan/Rules/Properties/ExistingClassesInPropertyHookTypehintsRuleTest.php new file mode 100644 index 0000000000..cab45fe36a --- /dev/null +++ b/tests/PHPStan/Rules/Properties/ExistingClassesInPropertyHookTypehintsRuleTest.php @@ -0,0 +1,65 @@ + + */ +class ExistingClassesInPropertyHookTypehintsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = $this->createReflectionProvider(); + return new ExistingClassesInPropertyHookTypehintsRule( + new FunctionDefinitionCheck( + $reflectionProvider, + new ClassNameCheck( + new ClassCaseSensitivityCheck($reflectionProvider, true), + new ClassForbiddenNameCheck(self::getContainer()), + ), + new UnresolvableTypeHelper(), + new PhpVersion(PHP_VERSION_ID), + true, + false, + ), + ); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/existing-classes-property-hooks.php'], [ + [ + 'Parameter $v of set hook for property ExistingClassesPropertyHooks\Foo::$i has invalid type ExistingClassesPropertyHooks\Nonexistent.', + 9, + ], + [ + 'Parameter $v of set hook for property ExistingClassesPropertyHooks\Foo::$j has unresolvable native type.', + 15, + ], + [ + 'Get hook for property ExistingClassesPropertyHooks\Foo::$k has invalid return type ExistingClassesPropertyHooks\Undefined.', + 22, + ], + [ + 'Parameter $value of set hook for property ExistingClassesPropertyHooks\Foo::$l has invalid type ExistingClassesPropertyHooks\Undefined.', + 29, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/existing-classes-property-hooks.php b/tests/PHPStan/Rules/Properties/data/existing-classes-property-hooks.php new file mode 100644 index 0000000000..a818f22c1e --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/existing-classes-property-hooks.php @@ -0,0 +1,34 @@ += 8.4 + +namespace ExistingClassesPropertyHooks; + +class Foo +{ + + public int $i { + set (Nonexistent $v) { + + } + } + + public \stdClass $j { + set (\stdClass&\Exception $v) { + + } + } + + /** @var Undefined */ + public $k { + get { + + } + } + + /** @var Undefined */ + public $l { + set { + + } + } + +} From 7f51614aa7c0ba0a584fd4f8c31877257e59de0c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 24 Dec 2024 14:19:13 +0100 Subject: [PATCH 38/41] Useful `getPropertyReflection()` shortcut in property hook virtual nodes --- src/Analyser/NodeScopeResolver.php | 15 ++++++++++++++- src/Node/InPropertyHookNode.php | 7 +++++++ src/Node/PropertyHookReturnStatementsNode.php | 7 +++++++ .../SetNonVirtualPropertyHookAssignRule.php | 6 +----- 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 7339f0a042..495879eaea 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4794,7 +4794,19 @@ private function processPropertyHooks( if (!$hookReflection instanceof PhpMethodFromParserNodeReflection) { throw new ShouldNotHappenException(); } - $nodeCallback(new InPropertyHookNode($classReflection, $hookReflection, $hook), $hookScope); + + if (!$classReflection->hasNativeProperty($propertyName)) { + throw new ShouldNotHappenException(); + } + + $propertyReflection = $classReflection->getNativeProperty($propertyName); + + $nodeCallback(new InPropertyHookNode( + $classReflection, + $hookReflection, + $propertyReflection, + $hook, + ), $hookScope); if ($hook->body instanceof Expr) { $this->processExprNode($stmt, $hook->body, $hookScope, $nodeCallback, ExpressionContext::createTopLevel()); @@ -4840,6 +4852,7 @@ private function processPropertyHooks( array_merge($statementResult->getImpurePoints(), $methodImpurePoints), $classReflection, $hookReflection, + $propertyReflection, ), $hookScope); } diff --git a/src/Node/InPropertyHookNode.php b/src/Node/InPropertyHookNode.php index b27899949d..99de6b73a0 100644 --- a/src/Node/InPropertyHookNode.php +++ b/src/Node/InPropertyHookNode.php @@ -6,6 +6,7 @@ use PhpParser\NodeAbstract; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; +use PHPStan\Reflection\Php\PhpPropertyReflection; /** * @api @@ -16,6 +17,7 @@ final class InPropertyHookNode extends NodeAbstract implements VirtualNode public function __construct( private ClassReflection $classReflection, private PhpMethodFromParserNodeReflection $hookReflection, + private PhpPropertyReflection $propertyReflection, private Node\PropertyHook $originalNode, ) { @@ -32,6 +34,11 @@ public function getHookReflection(): PhpMethodFromParserNodeReflection return $this->hookReflection; } + public function getPropertyReflection(): PhpPropertyReflection + { + return $this->propertyReflection; + } + public function getOriginalNode(): Node\PropertyHook { return $this->originalNode; diff --git a/src/Node/PropertyHookReturnStatementsNode.php b/src/Node/PropertyHookReturnStatementsNode.php index 7d97a140b9..42db85ee6d 100644 --- a/src/Node/PropertyHookReturnStatementsNode.php +++ b/src/Node/PropertyHookReturnStatementsNode.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\StatementResult; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; +use PHPStan\Reflection\Php\PhpPropertyReflection; /** * @api @@ -28,6 +29,7 @@ public function __construct( private array $impurePoints, private ClassReflection $classReflection, private PhpMethodFromParserNodeReflection $hookReflection, + private PhpPropertyReflection $propertyReflection, ) { parent::__construct($hook->getAttributes()); @@ -88,6 +90,11 @@ public function getHookReflection(): PhpMethodFromParserNodeReflection return $this->hookReflection; } + public function getPropertyReflection(): PhpPropertyReflection + { + return $this->propertyReflection; + } + public function getType(): string { return 'PHPStan_Node_PropertyHookReturnStatementsNode'; diff --git a/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php b/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php index 67f8f134bb..aeedaeb4a9 100644 --- a/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php +++ b/src/Rules/Properties/SetNonVirtualPropertyHookAssignRule.php @@ -37,11 +37,7 @@ public function processNode(Node $node, Scope $scope): array $propertyName = $hookReflection->getHookedPropertyName(); $classReflection = $node->getClassReflection(); - if (!$classReflection->hasNativeProperty($propertyName)) { - throw new ShouldNotHappenException(); - } - - $propertyReflection = $classReflection->getNativeProperty($propertyName); + $propertyReflection = $node->getPropertyReflection(); if ($propertyReflection->isVirtual()->yes()) { return []; } From 97710e63889faaf1c5df278805ed574f76d96ecd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 26 Dec 2024 08:23:42 +0100 Subject: [PATCH 39/41] ExtendedParameterReflection::hasNativeType() --- .../Annotations/AnnotationsMethodParameterReflection.php | 5 +++++ src/Reflection/ExtendedParameterReflection.php | 2 ++ src/Reflection/Native/ExtendedNativeParameterReflection.php | 6 ++++++ src/Reflection/Php/ExtendedDummyParameter.php | 6 ++++++ src/Reflection/Php/PhpParameterFromParserNodeReflection.php | 5 +++++ src/Reflection/Php/PhpParameterReflection.php | 5 +++++ 6 files changed, 29 insertions(+) diff --git a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php index 51bddcaabe..4f6b640785 100644 --- a/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php +++ b/src/Reflection/Annotations/AnnotationsMethodParameterReflection.php @@ -35,6 +35,11 @@ public function getPhpDocType(): Type return $this->type; } + public function hasNativeType(): bool + { + return false; + } + public function getNativeType(): Type { return new MixedType(); diff --git a/src/Reflection/ExtendedParameterReflection.php b/src/Reflection/ExtendedParameterReflection.php index db8df05ab8..aff5f65822 100644 --- a/src/Reflection/ExtendedParameterReflection.php +++ b/src/Reflection/ExtendedParameterReflection.php @@ -11,6 +11,8 @@ interface ExtendedParameterReflection extends ParameterReflection public function getPhpDocType(): Type; + public function hasNativeType(): bool; + public function getNativeType(): Type; public function getOutType(): ?Type; diff --git a/src/Reflection/Native/ExtendedNativeParameterReflection.php b/src/Reflection/Native/ExtendedNativeParameterReflection.php index 7e1388bf5a..90c653484b 100644 --- a/src/Reflection/Native/ExtendedNativeParameterReflection.php +++ b/src/Reflection/Native/ExtendedNativeParameterReflection.php @@ -5,6 +5,7 @@ use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\PassedByReference; use PHPStan\TrinaryLogic; +use PHPStan\Type\MixedType; use PHPStan\Type\Type; final class ExtendedNativeParameterReflection implements ExtendedParameterReflection @@ -46,6 +47,11 @@ public function getPhpDocType(): Type return $this->phpDocType; } + public function hasNativeType(): bool + { + return !$this->nativeType instanceof MixedType || $this->nativeType->isExplicitMixed(); + } + public function getNativeType(): Type { return $this->nativeType; diff --git a/src/Reflection/Php/ExtendedDummyParameter.php b/src/Reflection/Php/ExtendedDummyParameter.php index 91238c18b9..43151a7a7f 100644 --- a/src/Reflection/Php/ExtendedDummyParameter.php +++ b/src/Reflection/Php/ExtendedDummyParameter.php @@ -5,6 +5,7 @@ use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\PassedByReference; use PHPStan\TrinaryLogic; +use PHPStan\Type\MixedType; use PHPStan\Type\Type; final class ExtendedDummyParameter extends DummyParameter implements ExtendedParameterReflection @@ -32,6 +33,11 @@ public function getPhpDocType(): Type return $this->phpDocType; } + public function hasNativeType(): bool + { + return !$this->nativeType instanceof MixedType || $this->nativeType->isExplicitMixed(); + } + public function getNativeType(): Type { return $this->nativeType; diff --git a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php index 8ebb272bfd..f9bdddc13e 100644 --- a/src/Reflection/Php/PhpParameterFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpParameterFromParserNodeReflection.php @@ -63,6 +63,11 @@ public function getPhpDocType(): Type return $this->phpDocType ?? new MixedType(); } + public function hasNativeType(): bool + { + return !$this->realType instanceof MixedType || $this->realType->isExplicitMixed(); + } + public function getNativeType(): Type { return $this->realType; diff --git a/src/Reflection/Php/PhpParameterReflection.php b/src/Reflection/Php/PhpParameterReflection.php index 40b28e9ff6..c4c2713c4b 100644 --- a/src/Reflection/Php/PhpParameterReflection.php +++ b/src/Reflection/Php/PhpParameterReflection.php @@ -92,6 +92,11 @@ public function getPhpDocType(): Type return new MixedType(); } + public function hasNativeType(): bool + { + return $this->reflection->getType() !== null; + } + public function getNativeType(): Type { if ($this->nativeType === null) { From 2f6808adcff602ec74982770bdbacb01452f22b4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Tue, 24 Dec 2024 13:44:53 +0100 Subject: [PATCH 40/41] SetPropertyHookParameterRule - level 0 and 3 --- Makefile | 1 + conf/config.level0.neon | 7 ++ .../SetPropertyHookParameterRule.php | 105 ++++++++++++++++++ .../SetPropertyHookParameterRuleTest.php | 54 +++++++++ .../data/set-property-hook-parameter.php | 78 +++++++++++++ 5 files changed, 245 insertions(+) create mode 100644 src/Rules/Properties/SetPropertyHookParameterRule.php create mode 100644 tests/PHPStan/Rules/Properties/SetPropertyHookParameterRuleTest.php create mode 100644 tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php diff --git a/Makefile b/Makefile index bc702f32d4..1452d74f71 100644 --- a/Makefile +++ b/Makefile @@ -94,6 +94,7 @@ lint: --exclude tests/PHPStan/Parser/data/cleaning-property-hooks-before.php \ --exclude tests/PHPStan/Parser/data/cleaning-property-hooks-after.php \ --exclude tests/PHPStan/Rules/Properties/data/existing-classes-property-hooks.php \ + --exclude tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php \ src tests cs: diff --git a/conf/config.level0.neon b/conf/config.level0.neon index ff1a67c728..fc3bfc84f2 100644 --- a/conf/config.level0.neon +++ b/conf/config.level0.neon @@ -212,6 +212,13 @@ services: tags: - phpstan.rules.rule + - + class: PHPStan\Rules\Properties\SetPropertyHookParameterRule + arguments: + checkPhpDocMethodSignatures: %checkPhpDocMethodSignatures% + tags: + - phpstan.rules.rule + - class: PHPStan\Rules\Properties\UninitializedPropertyRule diff --git a/src/Rules/Properties/SetPropertyHookParameterRule.php b/src/Rules/Properties/SetPropertyHookParameterRule.php new file mode 100644 index 0000000000..941dd84973 --- /dev/null +++ b/src/Rules/Properties/SetPropertyHookParameterRule.php @@ -0,0 +1,105 @@ + + */ +final class SetPropertyHookParameterRule implements Rule +{ + + public function __construct(private bool $checkPhpDocMethodSignatures) + { + } + + public function getNodeType(): string + { + return InPropertyHookNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $hookReflection = $node->getHookReflection(); + if (!$hookReflection->isPropertyHook()) { + return []; + } + + if ($hookReflection->getPropertyHookName() !== 'set') { + return []; + } + + $propertyReflection = $node->getPropertyReflection(); + $parameters = $hookReflection->getParameters(); + if (!isset($parameters[0])) { + throw new ShouldNotHappenException(); + } + + $classReflection = $node->getClassReflection(); + + $errors = []; + $parameter = $parameters[0]; + if (!$propertyReflection->hasNativeType()) { + if ($parameter->hasNativeType()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter $%s of set hook has a native type but the property %s::$%s does not.', + $parameter->getName(), + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ))->identifier('propertySetHook.nativeParameterType') + ->nonIgnorable() + ->build(); + } + } elseif (!$parameter->hasNativeType()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Parameter $%s of set hook does not have a native type but the property %s::$%s does.', + $parameter->getName(), + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ))->identifier('propertySetHook.nativeParameterType') + ->nonIgnorable() + ->build(); + } else { + if (!$parameter->getNativeType()->isSuperTypeOf($propertyReflection->getNativeType())->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Native type %s of set hook parameter $%s is not contravariant with native type %s of property %s::$%s.', + $parameter->getNativeType()->describe(VerbosityLevel::typeOnly()), + $parameter->getName(), + $propertyReflection->getNativeType()->describe(VerbosityLevel::typeOnly()), + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ))->identifier('propertySetHook.nativeParameterType') + ->nonIgnorable() + ->build(); + } + } + + if (!$this->checkPhpDocMethodSignatures || count($errors) > 0) { + return $errors; + } + + if (!$parameter->getType()->isSuperTypeOf($propertyReflection->getReadableType())->yes()) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Type %s of set hook parameter $%s is not contravariant with type %s of property %s::$%s.', + $parameter->getType()->describe(VerbosityLevel::value()), + $parameter->getName(), + $propertyReflection->getReadableType()->describe(VerbosityLevel::value()), + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + ))->identifier('propertySetHook.parameterType') + ->build(); + } + + return $errors; + } + +} diff --git a/tests/PHPStan/Rules/Properties/SetPropertyHookParameterRuleTest.php b/tests/PHPStan/Rules/Properties/SetPropertyHookParameterRuleTest.php new file mode 100644 index 0000000000..76e7b06b8c --- /dev/null +++ b/tests/PHPStan/Rules/Properties/SetPropertyHookParameterRuleTest.php @@ -0,0 +1,54 @@ + + */ +class SetPropertyHookParameterRuleTest extends RuleTestCase +{ + + protected function getRule(): TRule + { + return new SetPropertyHookParameterRule(true); + } + + public function testRule(): void + { + if (PHP_VERSION_ID < 80400) { + $this->markTestSkipped('Test requires PHP 8.4.'); + } + + $this->analyse([__DIR__ . '/data/set-property-hook-parameter.php'], [ + [ + 'Parameter $v of set hook has a native type but the property SetPropertyHookParameter\Bar::$a does not.', + 41, + ], + [ + 'Parameter $v of set hook does not have a native type but the property SetPropertyHookParameter\Bar::$b does.', + 47, + ], + [ + 'Native type string of set hook parameter $v is not contravariant with native type int of property SetPropertyHookParameter\Bar::$c.', + 53, + ], + [ + 'Native type string of set hook parameter $v is not contravariant with native type int|string of property SetPropertyHookParameter\Bar::$d.', + 59, + ], + [ + 'Type int<1, max> of set hook parameter $v is not contravariant with type int of property SetPropertyHookParameter\Bar::$e.', + 66, + ], + [ + 'Type array|int<1, max> of set hook parameter $v is not contravariant with type int of property SetPropertyHookParameter\Bar::$f.', + 73, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php b/tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php new file mode 100644 index 0000000000..a8279832b2 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php @@ -0,0 +1,78 @@ + */ + set (int|array $v) { + + } + } + + public $ok4 { + set ($v) { + + } + } + +} + +class Bar +{ + + public $a { + set (int $v) { + + } + } + + public int $b { + set ($v) { + + } + } + + public int $c { + set (string $v) { + + } + } + + public int|string $d { + set (string $v) { + + } + } + + public int $e { + /** @param positive-int $v */ + set (int $v) { + + } + } + + public int $f { + /** @param positive-int|array $v */ + set (int|array $v) { + + } + } + +} From 85e0c1f23b7378e8d1c3731ef789f0e83dbd1455 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Thu, 26 Dec 2024 14:47:21 +0100 Subject: [PATCH 41/41] Report missing types in SetPropertyHookParameterRule - level 6 --- conf/config.level0.neon | 1 + .../SetPropertyHookParameterRule.php | 58 ++++++++++++++++- .../SetPropertyHookParameterRuleTest.php | 16 ++++- .../data/set-property-hook-parameter.php | 64 ++++++++++++++++++- 4 files changed, 134 insertions(+), 5 deletions(-) diff --git a/conf/config.level0.neon b/conf/config.level0.neon index fc3bfc84f2..6493abd868 100644 --- a/conf/config.level0.neon +++ b/conf/config.level0.neon @@ -216,6 +216,7 @@ services: class: PHPStan\Rules\Properties\SetPropertyHookParameterRule arguments: checkPhpDocMethodSignatures: %checkPhpDocMethodSignatures% + checkMissingTypehints: %checkMissingTypehints% tags: - phpstan.rules.rule diff --git a/src/Rules/Properties/SetPropertyHookParameterRule.php b/src/Rules/Properties/SetPropertyHookParameterRule.php index 941dd84973..e8de30667e 100644 --- a/src/Rules/Properties/SetPropertyHookParameterRule.php +++ b/src/Rules/Properties/SetPropertyHookParameterRule.php @@ -5,6 +5,7 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\Node\InPropertyHookNode; +use PHPStan\Rules\MissingTypehintCheck; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\ShouldNotHappenException; @@ -18,7 +19,11 @@ final class SetPropertyHookParameterRule implements Rule { - public function __construct(private bool $checkPhpDocMethodSignatures) + public function __construct( + private MissingTypehintCheck $missingTypehintCheck, + private bool $checkPhpDocMethodSignatures, + private bool $checkMissingTypehints, + ) { } @@ -87,10 +92,12 @@ public function processNode(Node $node, Scope $scope): array return $errors; } - if (!$parameter->getType()->isSuperTypeOf($propertyReflection->getReadableType())->yes()) { + $parameterType = $parameter->getType(); + + if (!$parameterType->isSuperTypeOf($propertyReflection->getReadableType())->yes()) { $errors[] = RuleErrorBuilder::message(sprintf( 'Type %s of set hook parameter $%s is not contravariant with type %s of property %s::$%s.', - $parameter->getType()->describe(VerbosityLevel::value()), + $parameterType->describe(VerbosityLevel::value()), $parameter->getName(), $propertyReflection->getReadableType()->describe(VerbosityLevel::value()), $classReflection->getDisplayName(), @@ -99,6 +106,51 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + if (!$this->checkMissingTypehints) { + return $errors; + } + + if ($parameter->getNativeType()->equals($propertyReflection->getReadableType())) { + return $errors; + } + + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($parameterType) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + 'Set hook for property %s::$%s has parameter $%s with no value type specified in iterable type %s.', + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $parameter->getName(), + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($parameterType) as [$name, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Set hook for property %s::$%s has parameter $%s with generic %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $parameter->getName(), + $name, + $genericTypeNames, + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Set hook for property %s::$%s has parameter $%s with no signature specified for %s.', + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $parameter->getName(), + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + return $errors; } diff --git a/tests/PHPStan/Rules/Properties/SetPropertyHookParameterRuleTest.php b/tests/PHPStan/Rules/Properties/SetPropertyHookParameterRuleTest.php index 76e7b06b8c..0b879f0ad5 100644 --- a/tests/PHPStan/Rules/Properties/SetPropertyHookParameterRuleTest.php +++ b/tests/PHPStan/Rules/Properties/SetPropertyHookParameterRuleTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Rules\Properties; +use PHPStan\Rules\MissingTypehintCheck; use PHPStan\Rules\Rule as TRule; use PHPStan\Testing\RuleTestCase; use const PHP_VERSION_ID; @@ -14,7 +15,7 @@ class SetPropertyHookParameterRuleTest extends RuleTestCase protected function getRule(): TRule { - return new SetPropertyHookParameterRule(true); + return new SetPropertyHookParameterRule(new MissingTypehintCheck(true, []), true, true); } public function testRule(): void @@ -48,6 +49,19 @@ public function testRule(): void 'Type array|int<1, max> of set hook parameter $v is not contravariant with type int of property SetPropertyHookParameter\Bar::$f.', 73, ], + [ + 'Set hook for property SetPropertyHookParameter\MissingTypes::$f has parameter $v with no value type specified in iterable type array.', + 123, + 'See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type', + ], + [ + 'Set hook for property SetPropertyHookParameter\MissingTypes::$g has parameter $value with generic class SetPropertyHookParameter\GenericFoo but does not specify its types: T', + 129, + ], + [ + 'Set hook for property SetPropertyHookParameter\MissingTypes::$h has parameter $value with no signature specified for callable.', + 135, + ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php b/tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php index a8279832b2..12c82ddc0a 100644 --- a/tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php +++ b/tests/PHPStan/Rules/Properties/data/set-property-hook-parameter.php @@ -20,7 +20,7 @@ class Foo /** @var positive-int */ public int $ok3 { - /** @param positive-int|array */ + /** @param positive-int|array $v */ set (int|array $v) { } @@ -76,3 +76,65 @@ class Bar } } + +/** + * @template T + */ +class GenericFoo +{ + +} + +class MissingTypes +{ + + public array $a { + set { // do not report, taken care of above the property + } + } + + /** @var array */ + public array $b { + set { // do not report, inherited from property + } + } + + public array $c { + set (array $v) { // do not report, taken care of above the property + + } + } + + /** @var array */ + public array $d { + set (array $v) { // do not report, inherited from property + + } + } + + public int $e { + /** @param array $v */ + set (int|array $v) { // do not report, type specified + + } + } + + public int $f { + set (int|array $v) { // report + + } + } + + public int $g { + set (int|GenericFoo $value) { // report + + } + } + + public int $h { + set (int|callable $value) { // report + + } + } + +}