diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index ad3c1c67af..03669ecc0c 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -6,8 +6,6 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; use PhpParser\Node\Expr\BinaryOp; -use PhpParser\Node\Expr\ConstFetch; -use PhpParser\Node\Name; use PhpParser\Node\Stmt; use PHPStan\Analyser\AssignTargetWalkMode; use PHPStan\Analyser\ExpressionContext; @@ -77,7 +75,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $valueContext = $context; if ($expr instanceof Expr\AssignOp\Coalesce) { $valueScope = $valueScope->filterByFalseyValue( - new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), + new Expr\Isset_([$expr->var]), ); if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15021.php b/tests/PHPStan/Analyser/nsrt/bug-15021.php new file mode 100644 index 0000000000..7864d1c5d2 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15021.php @@ -0,0 +1,185 @@ + $data */ +function nonConstantArray(array $data, string $key): void +{ + $data[$key] ??= assertType('array', $data); +} + +/** @param array $data */ +function nonConstantArrayConstantKey(array $data): void +{ + $data['foo'] ??= assertType('array', $data); +} + +/** @param array $data */ +function nonConstantArrayNullableValue(array $data): void +{ + $data['foo'] ??= assertType('string|null', $data['foo']); +} + +/** @param array{a?: array{b?: string, c?: string}} $data */ +function nestedOptionalOffset(array $data): void +{ + $data['a']['b'] ??= assertType('array{a?: array{b?: string, c?: string}}', $data); +} + +class Foo +{ + + public string $nonNullable = ''; + + public static string $staticNonNullable = ''; + + /** @var array{foo?: string, bar?: string} */ + public array $data = []; + +} + +function nonNullableProperty(Foo $foo): void +{ + $foo->nonNullable ??= assertType('string', $foo->nonNullable); +} + +function nonNullableStaticProperty(): void +{ + Foo::$staticNonNullable ??= assertType('string', Foo::$staticNonNullable); +} + +function propertyOffset(Foo $foo): void +{ + $foo->data['foo'] ??= assertType('array{foo?: string, bar?: string}', $foo->data); +} + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccess(\ArrayAccess $a): void +{ + $a['foo'] ??= assertType('string|null', $a['foo']); +} + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccessCoalesce(\ArrayAccess $a): void +{ + $x = $a['foo'] ?? assertType('string|null', $a['foo']); +} + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccessDifferentOffset(\ArrayAccess $a): void +{ + $a['foo'] ??= assertType('string|null', $a['bar']); +} + +/** @param \ArrayAccess $a */ +function nullableOffsetAccess(\ArrayAccess $a): void +{ + $a['foo'] ??= assertType('string|null', $a['foo']); +} + +/** @param \ArrayAccess $a */ +function nullableOffsetAccessCoalesce(\ArrayAccess $a): void +{ + $x = $a['foo'] ?? assertType('string|null', $a['foo']); +} + +/** @param \ArrayObject $data */ +function arrayObjectOffset(\ArrayObject $data): void +{ + $data['foo'] ??= assertType('string|null', $data['foo']); +} + +/** @param \ArrayObject $data */ +function arrayObjectOffsetCoalesce(\ArrayObject $data): void +{ + $x = $data['foo'] ?? assertType('string|null', $data['foo']); +} + +/** @param array{foo?: string, bar?: string} $data */ +function issetOnRightSide(array $data): void +{ + $data['foo'] ??= isset($data['bar']) ? assertType('string', $data['bar']) : 'fallback'; +} + +/** @param array{foo?: string, bar?: string} $data */ +function emptyOnRightSide(array $data): void +{ + $data['foo'] ??= empty($data['bar']) ? 'fallback' : assertType('non-falsy-string', $data['bar']); +} + +/** @param array{foo?: string, bar?: string} $data */ +function unsetTargetBeforeAssignOp(array $data): void +{ + unset($data['foo']); + $data['foo'] ??= assertType('array{bar?: string}', $data); +} + +/** @param array{foo?: string, bar?: string} $data */ +function emptyTargetBeforeAssignOp(array $data): void +{ + if (empty($data['foo'])) { + $data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data); + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideEmpty(array $data): void +{ + if (empty($data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data))) { + echo 'empty'; + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideIsset(array $data): void +{ + if (isset($data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data))) { + echo 'isset'; + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideUnsetOffset(array $data): void +{ + $other = ['x' => 1, 'fallback' => 2]; + unset($other[$data['foo'] ??= assertType('array{foo?: string, bar?: string}', $data)]); +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpResultInsideEmpty(array $data): void +{ + if (empty($data['foo'] ??= $data['bar'] ?? 'fallback')) { + assertType("array{foo: ''|'0', bar?: string}", $data); + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpResultInsideIsset(array $data): void +{ + if (isset($data['foo'] ??= $data['bar'] ?? null)) { + assertType('array{foo: string, bar?: string}', $data); + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function unsetAssignOpResultOffset(array $data): void +{ + $other = ['x' => 1, 'fallback' => 2]; + unset($other[$data['foo'] ??= $data['bar'] ?? 'fallback']); + assertType('array{x?: 1, fallback?: 2}', $other); +} diff --git a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php index 079b032e8e..6ab498be10 100644 --- a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php +++ b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php @@ -250,4 +250,11 @@ public function testIssetAfterRememberedConstructor(): void ]); } + public function testNullCoalesceAssignRightSideScope(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index af8f1542e0..db77f2aef5 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -610,4 +610,11 @@ public function testBug14610(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-14610.php'], []); } + public function testNullCoalesceAssignRightSideScope(): void + { + $this->treatPhpDocTypesAsCertain = true; + + $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 17d1dd8594..4c39265508 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -558,4 +558,43 @@ public function testBug14393(): void ]); } + public function testBug15021(): void + { + $this->analyse([__DIR__ . '/data/bug-15021.php'], []); + } + + public function testNullCoalesceAssignRightSideScope(): void + { + $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], [ + [ + 'Property NullCoalesceAssignRightSideScope\Foo::$nonNullable (string) on left side of ??= is not nullable.', + 19, + ], + [ + 'Property NullCoalesceAssignRightSideScope\Foo::$nonNullable (string) on left side of ?? is not nullable.', + 19, + ], + [ + 'Static property NullCoalesceAssignRightSideScope\Foo::$staticNonNullable (string) on left side of ??= is not nullable.', + 24, + ], + [ + 'Static property NullCoalesceAssignRightSideScope\Foo::$staticNonNullable (string) on left side of ?? is not nullable.', + 24, + ], + [ + 'Variable $undefined on left side of ??= is never defined.', + 46, + ], + [ + 'Variable $undefined on left side of ?? is never defined.', + 46, + ], + [ + 'Offset \'foo\' on array{bar?: string} on left side of ??= does not exist.', + 89, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/UnsetRuleTest.php b/tests/PHPStan/Rules/Variables/UnsetRuleTest.php index 3bef8ab900..73515e60d5 100644 --- a/tests/PHPStan/Rules/Variables/UnsetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/UnsetRuleTest.php @@ -224,4 +224,9 @@ public function testUnsetHookedProperty(): void ]); } + public function testNullCoalesceAssignRightSideScope(): void + { + $this->analyse([__DIR__ . '/data/null-coalesce-assign-right-side-scope.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-15021.php b/tests/PHPStan/Rules/Variables/data/bug-15021.php new file mode 100644 index 0000000000..9d23d7734a --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-15021.php @@ -0,0 +1,8 @@ +nonNullable ??= $foo->nonNullable ?? null; +} + +function staticProperty(): void +{ + Foo::$staticNonNullable ??= Foo::$staticNonNullable ?? null; +} + +function propertyOffset(Foo $foo): void +{ + $foo->data['foo'] ??= $foo->data['bar'] ?? null; +} + +/** @param array{a?: array{b?: string, c?: string}} $data */ +function nestedOffset(array $data): void +{ + $data['a']['b'] ??= $data['a']['c'] ?? null; +} + +/** @param array $data */ +function nonConstantArray(array $data, string $key): void +{ + $data[$key] ??= $data['fallback'] ?? null; +} + +function undefinedVariable(): void +{ + $undefined ??= $undefined ?? 1; +} + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccess(\ArrayAccess $a): void +{ + $a['foo'] ??= $a['foo'] ?? null; +} + +/** @param \ArrayAccess $a */ +function nonNullableOffsetAccessDifferentOffset(\ArrayAccess $a): void +{ + $a['foo'] ??= $a['bar'] ?? null; +} + +/** @param \ArrayAccess $a */ +function nullableOffsetAccess(\ArrayAccess $a): void +{ + $a['foo'] ??= $a['foo'] ?? null; +} + +/** @param \ArrayObject $data */ +function arrayObjectOffset(\ArrayObject $data): void +{ + $data['foo'] ??= $data['foo'] ?? null; +} + +/** @param array{foo?: string, bar?: string} $data */ +function issetOnRightSide(array $data): void +{ + $data['foo'] ??= isset($data['bar']) ? $data['bar'] : 'fallback'; +} + +/** @param array{foo?: string, bar?: string} $data */ +function emptyOnRightSide(array $data): void +{ + $data['foo'] ??= empty($data['bar']) ? 'fallback' : $data['bar']; +} + +/** @param array{foo?: string, bar?: string} $data */ +function unsetTargetBeforeAssignOp(array $data): void +{ + unset($data['foo']); + $data['foo'] ??= $data['bar'] ?? null; +} + +/** @param array{foo?: string, bar?: string} $data */ +function emptyTargetBeforeAssignOp(array $data): void +{ + if (empty($data['foo'])) { + $data['foo'] ??= $data['bar'] ?? null; + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideEmpty(array $data): void +{ + if (empty($data['foo'] ??= $data['bar'] ?? null)) { + echo 'empty'; + } +} + +/** @param array{foo?: string, bar?: string} $data */ +function assignOpInsideUnsetOffset(array $data): void +{ + $other = ['x' => 1, 'fallback' => 2]; + unset($other[$data['foo'] ??= $data['bar'] ?? 'fallback']); +}