diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 97154907b6..11f30df85c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -691,7 +691,7 @@ private function processStmtNode( $this->processAttributeGroups($stmt->attrGroups, $classScope, $classStatementsGatherer); $this->processStmtNodes($stmt, $stmt->stmts, $classScope, $classStatementsGatherer, $context); - $nodeCallback(new ClassPropertiesNode($stmt, $this->readWritePropertiesExtensionProvider, $classStatementsGatherer->getProperties(), $classStatementsGatherer->getPropertyUsages(), $classStatementsGatherer->getMethodCalls()), $classScope); + $nodeCallback(new ClassPropertiesNode($stmt, $this->readWritePropertiesExtensionProvider, $classStatementsGatherer->getProperties(), $classStatementsGatherer->getPropertyUsages(), $classStatementsGatherer->getMethodCalls(), $classStatementsGatherer->getReturnStatementsNodes()), $classScope); $nodeCallback(new ClassMethodsNode($stmt, $classStatementsGatherer->getMethods(), $classStatementsGatherer->getMethodCalls()), $classScope); $nodeCallback(new ClassConstantsNode($stmt, $classStatementsGatherer->getConstants(), $classStatementsGatherer->getConstantFetches()), $classScope); $classReflection->evictPrivateSymbols(); @@ -3814,6 +3814,9 @@ private function processAssignVar( } else { if ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeCallback(new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scope); + if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { + $scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString()); + } } $scope = $scope->assignExpression( $var, @@ -3835,6 +3838,9 @@ private function processAssignVar( } else { if ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeCallback(new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scope); + if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { + $scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString()); + } } } diff --git a/src/Node/ClassPropertiesNode.php b/src/Node/ClassPropertiesNode.php index f0a79debaf..8a702b448e 100644 --- a/src/Node/ClassPropertiesNode.php +++ b/src/Node/ClassPropertiesNode.php @@ -21,10 +21,12 @@ use PHPStan\Rules\Properties\ReadWritePropertiesExtensionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\NeverType; use PHPStan\Type\TypeUtils; use function array_key_exists; use function array_keys; use function in_array; +use function strtolower; /** @api */ class ClassPropertiesNode extends NodeAbstract implements VirtualNode @@ -34,6 +36,7 @@ class ClassPropertiesNode extends NodeAbstract implements VirtualNode * @param ClassPropertyNode[] $properties * @param array $propertyUsages * @param array $methodCalls + * @param array $returnStatementNodes */ public function __construct( private ClassLike $class, @@ -41,6 +44,7 @@ public function __construct( private array $properties, private array $propertyUsages, private array $methodCalls, + private array $returnStatementNodes, ) { parent::__construct($class->getAttributes()); @@ -191,10 +195,6 @@ public function getUninitializedProperties( } if ($usage instanceof PropertyWrite) { - if (array_key_exists($propertyName, $uninitializedProperties)) { - unset($uninitializedProperties[$propertyName]); - } - if (array_key_exists($propertyName, $initializedPropertiesMap)) { $hasInitialization = $initializedPropertiesMap[$propertyName]->or($usageScope->hasExpressionType(new PropertyInitializationExpr($propertyName))); if (!$hasInitialization->no() && !$usage->isPromotedPropertyWrite()) { @@ -217,6 +217,57 @@ public function getUninitializedProperties( } } + foreach (array_keys($methodsCalledFromConstructor) as $constructor) { + $lowerConstructorName = strtolower($constructor); + if (!array_key_exists($lowerConstructorName, $this->returnStatementNodes)) { + continue; + } + + $returnStatementsNode = $this->returnStatementNodes[$lowerConstructorName]; + $methodScope = null; + foreach ($returnStatementsNode->getExecutionEnds() as $executionEnd) { + $statementResult = $executionEnd->getStatementResult(); + $endNode = $executionEnd->getNode(); + if ($statementResult->isAlwaysTerminating()) { + if ($endNode instanceof Node\Stmt\Throw_) { + continue; + } + if ($endNode instanceof Node\Stmt\Expression) { + $exprType = $statementResult->getScope()->getType($endNode->expr); + if ($exprType instanceof NeverType && $exprType->isExplicit()) { + continue; + } + } + } + if ($methodScope === null) { + $methodScope = $statementResult->getScope(); + continue; + } + + $methodScope = $methodScope->mergeWith($statementResult->getScope()); + } + + foreach ($returnStatementsNode->getReturnStatements() as $returnStatement) { + if ($methodScope === null) { + $methodScope = $returnStatement->getScope(); + continue; + } + $methodScope = $methodScope->mergeWith($returnStatement->getScope()); + } + + if ($methodScope === null) { + continue; + } + + foreach (array_keys($uninitializedProperties) as $propertyName) { + if (!$methodScope->hasExpressionType(new PropertyInitializationExpr($propertyName))->yes()) { + continue; + } + + unset($uninitializedProperties[$propertyName]); + } + } + return [ $uninitializedProperties, $prematureAccess, diff --git a/src/Node/ClassStatementsGatherer.php b/src/Node/ClassStatementsGatherer.php index 82613c369e..904186224f 100644 --- a/src/Node/ClassStatementsGatherer.php +++ b/src/Node/ClassStatementsGatherer.php @@ -20,6 +20,7 @@ use PHPStan\Type\TypeUtils; use function count; use function in_array; +use function strtolower; class ClassStatementsGatherer { @@ -50,6 +51,9 @@ class ClassStatementsGatherer /** @var ClassConstantFetch[] */ private array $constantFetches = []; + /** @var array */ + private array $returnStatementNodes = []; + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -109,6 +113,14 @@ public function getConstantFetches(): array return $this->constantFetches; } + /** + * @return array + */ + public function getReturnStatementsNodes(): array + { + return $this->returnStatementNodes; + } + public function __invoke(Node $node, Scope $scope): void { $nodeCallback = $this->nodeCallback; @@ -151,6 +163,10 @@ private function gatherNodes(Node $node, Scope $scope): void $this->methodCalls[] = new \PHPStan\Node\Method\MethodCall($node->getOriginalNode(), $scope); return; } + if ($node instanceof MethodReturnStatementsNode) { + $this->returnStatementNodes[strtolower($node->getMethodName())] = $node; + return; + } if ( $node instanceof Expr\FuncCall && $node->name instanceof Node\Name diff --git a/src/Node/MethodReturnStatementsNode.php b/src/Node/MethodReturnStatementsNode.php index dcf6bb8536..fc39507c05 100644 --- a/src/Node/MethodReturnStatementsNode.php +++ b/src/Node/MethodReturnStatementsNode.php @@ -57,6 +57,11 @@ public function hasNativeReturnTypehint(): bool return $this->classMethod->returnType !== null; } + public function getMethodName(): string + { + return $this->classMethod->name->toString(); + } + public function getYieldStatements(): array { return $this->yieldStatements; diff --git a/tests/PHPStan/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php index 0177b6ab36..c7a90b03ab 100644 --- a/tests/PHPStan/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/MissingReadOnlyByPhpDocPropertyAssignRuleTest.php @@ -84,6 +84,10 @@ public function testRule(): void 'Class MissingReadOnlyPropertyAssignPhpDoc\BarDoubleAssignInSetter has an uninitialized @readonly property $foo. Assign it in the constructor.', 57, ], + [ + 'Class MissingReadOnlyPropertyAssignPhpDoc\AssignOp has an uninitialized @readonly property $foo. Assign it in the constructor.', + 85, + ], [ 'Access to an uninitialized @readonly property MissingReadOnlyPropertyAssignPhpDoc\AssignOp::$foo.', 92, diff --git a/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php b/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php index d6caf0efa4..821b69297c 100644 --- a/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php +++ b/tests/PHPStan/Rules/Properties/MissingReadOnlyPropertyAssignRuleTest.php @@ -84,6 +84,10 @@ public function testRule(): void 'Class MissingReadOnlyPropertyAssign\BarDoubleAssignInSetter has an uninitialized readonly property $foo. Assign it in the constructor.', 53, ], + [ + 'Class MissingReadOnlyPropertyAssign\AssignOp has an uninitialized readonly property $foo. Assign it in the constructor.', + 79, + ], [ 'Access to an uninitialized readonly property MissingReadOnlyPropertyAssign\AssignOp::$foo.', 85, @@ -195,4 +199,18 @@ public function testBug7198(): void $this->analyse([__DIR__ . '/data/bug-7198.php'], []); } + public function testBug7649(): void + { + if (PHP_VERSION_ID < 80100) { + $this->markTestSkipped('Test requires PHP 8.1.'); + } + + $this->analyse([__DIR__ . '/data/bug-7649.php'], [ + [ + 'Class Bug7649\Foo has an uninitialized readonly property $bar. Assign it in the constructor.', + 7, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php index 7eec93a0a8..ba1fc29f10 100644 --- a/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php +++ b/tests/PHPStan/Rules/Properties/UninitializedPropertyRuleTest.php @@ -91,10 +91,18 @@ public function testRule(): void 'Access to an uninitialized property UninitializedProperty\InitializedInPublicSetterNonFinalClass::$foo.', 278, ],*/ + [ + 'Class UninitializedProperty\SometimesInitializedInPrivateSetter has an uninitialized property $foo. Give it default value or assign it in the constructor.', + 286, + ], [ 'Access to an uninitialized property UninitializedProperty\SometimesInitializedInPrivateSetter::$foo.', 303, ], + [ + 'Class UninitializedProperty\EarlyReturn has an uninitialized property $foo. Give it default value or assign it in the constructor.', + 372, + ], ]); } diff --git a/tests/PHPStan/Rules/Properties/data/bug-7649.php b/tests/PHPStan/Rules/Properties/data/bug-7649.php new file mode 100644 index 0000000000..c0d0626a90 --- /dev/null +++ b/tests/PHPStan/Rules/Properties/data/bug-7649.php @@ -0,0 +1,16 @@ +bar = 'baz'; + } else { + } + } +} diff --git a/tests/PHPStan/Rules/Properties/data/uninitialized-property.php b/tests/PHPStan/Rules/Properties/data/uninitialized-property.php index e3fa2865e1..dc79bc5468 100644 --- a/tests/PHPStan/Rules/Properties/data/uninitialized-property.php +++ b/tests/PHPStan/Rules/Properties/data/uninitialized-property.php @@ -332,3 +332,77 @@ public function doSomething() } } + +class ThrowInConstructor1 +{ + + private int $foo; + + public function __construct() + { + if (rand(0, 1)) { + $this->foo = 1; + return; + } + + throw new \Exception; + } + +} + +class ThrowInConstructor2 +{ + + private int $foo; + + public function __construct() + { + if (rand(0, 1)) { + throw new \Exception; + } + + $this->foo = 1; + } + +} + +class EarlyReturn +{ + + private int $foo; + + public function __construct() + { + if (rand(0, 1)) { + return; + } + + $this->foo = 1; + } + +} + +class NeverInConstructor +{ + + private int $foo; + + public function __construct() + { + if (rand(0, 1)) { + $this->foo = 1; + return; + } + + $this->returnNever(); + } + + /** + * @return never + */ + private function returnNever() + { + throw new \Exception(); + } + +}