diff --git a/src/Rules/Arrays/ArrayDestructuringRule.php b/src/Rules/Arrays/ArrayDestructuringRule.php index e0d9327315a..e6664c65e8e 100644 --- a/src/Rules/Arrays/ArrayDestructuringRule.php +++ b/src/Rules/Arrays/ArrayDestructuringRule.php @@ -13,10 +13,13 @@ use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\RuleLevelHelper; +use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\ErrorType; +use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use PHPStan\Type\VerbosityLevel; use function array_merge; use function sprintf; @@ -68,15 +71,19 @@ private function getErrors(Scope $scope, Node\Expr\List_ $var, Expr $expr): arra if ($exprType instanceof ErrorType) { return []; } + $errors = []; if (!$exprType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($exprType)->yes()) { - return [ - RuleErrorBuilder::message(sprintf('Cannot use array destructuring on %s.', $exprType->describe(VerbosityLevel::typeOnly()))) - ->identifier('offsetAccess.nonArray') - ->build(), - ]; + $errors[] = RuleErrorBuilder::message(sprintf('Cannot use array destructuring on %s.', $exprType->describe(VerbosityLevel::typeOnly()))) + ->identifier('offsetAccess.nonArray') + ->build(); + if (!$exprType->isArray()->maybe()) { + return $errors; + } + + // array{0?: int}|false: the keys can also be missing on the array part + $expr = new TypeExpr(TypeCombinator::intersect($exprType, new ArrayType(new MixedType(), new MixedType()))); } - $errors = []; $i = 0; foreach ($var->items as $item) { if ($item === null) { diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php index 15ad08f0e30..37555f4ca7d 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchCheck.php @@ -98,6 +98,11 @@ public function check( : TypeCombinator::intersect(AllowedArrayKeysTypes::getType(), $dimType); foreach ($flattenedTypes as $innerType) { + if (!$innerType->isOffsetAccessible()->yes()) { + // array{host: string}|false: the rule already reports the offset access on false + continue; + } + $dimTypeToCheck = $innerType->isArray()->yes() ? $validArrayDimType : $dimType; if ( diff --git a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php index 9a4947d5216..a77ff6e7e07 100644 --- a/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php +++ b/src/Rules/Arrays/NonexistentOffsetInArrayDimFetchRule.php @@ -10,6 +10,7 @@ use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; use PHPStan\Internal\SprintfHelper; +use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Rules\RuleLevelHelper; @@ -18,6 +19,7 @@ use PHPStan\Type\ErrorType; use PHPStan\Type\Type; use PHPStan\Type\VerbosityLevel; +use function array_merge; use function count; use function in_array; use function is_string; @@ -83,18 +85,13 @@ public function processNode(Node $node, Scope $scope): array return []; } + $errors = []; if (!$isOffsetAccessible->yes()) { - if ($isOffsetAccessible->no() || $this->reportMaybes) { - if ($dimType !== null) { - return [ - RuleErrorBuilder::message(sprintf( - 'Cannot access offset %s on %s.', - $dimType->describe(count($dimType->getConstantStrings()) > 0 ? VerbosityLevel::precise() : VerbosityLevel::value()), - $isOffsetAccessibleType->describe(VerbosityLevel::value()), - ))->identifier('offsetAccess.nonOffsetAccessible')->build(), - ]; - } + if (!$isOffsetAccessible->no() && !$this->reportMaybes) { + return []; + } + if ($dimType === null) { return [ RuleErrorBuilder::message(sprintf( 'Cannot access an offset on %s.', @@ -103,13 +100,31 @@ public function processNode(Node $node, Scope $scope): array ]; } - return []; + $errors[] = RuleErrorBuilder::message(sprintf( + 'Cannot access offset %s on %s.', + $dimType->describe(count($dimType->getConstantStrings()) > 0 ? VerbosityLevel::precise() : VerbosityLevel::value()), + $isOffsetAccessibleType->describe(VerbosityLevel::value()), + ))->identifier('offsetAccess.nonOffsetAccessible')->build(); + // a write creates the offset, so only a read can find it missing + if ($isOffsetAccessible->no() || $scope->isInExpressionAssign($node)) { + return $errors; + } + + // array{host?: string}|false: the offset can also be missing on the offset-accessible part } if ($dimType === null) { - return []; + return $errors; } + return array_merge($errors, $this->checkOffset($node, $scope, $dimType, $unknownClassPattern)); + } + + /** + * @return list + */ + private function checkOffset(Node\Expr\ArrayDimFetch $node, Scope $scope, Type $dimType, string $unknownClassPattern): array + { if ( $node->dim instanceof Node\Expr\FuncCall && !$node->dim->isFirstClassCallable() diff --git a/tests/PHPStan/Levels/data/arrayDimFetches-7.json b/tests/PHPStan/Levels/data/arrayDimFetches-7.json index 23df32943ad..990e758ab8c 100644 --- a/tests/PHPStan/Levels/data/arrayDimFetches-7.json +++ b/tests/PHPStan/Levels/data/arrayDimFetches-7.json @@ -14,6 +14,11 @@ "line": 28, "ignorable": true }, + { + "message": "Offset 'b' might not exist on array{a: 1}|stdClass.", + "line": 28, + "ignorable": true + }, { "message": "Offset 'b' might not exist on array{a: 1, b?: 1}.", "line": 40, diff --git a/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php b/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php index 521b53d463b..b9ad84db4da 100644 --- a/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/ArrayDestructuringRuleTest.php @@ -123,4 +123,34 @@ public function testRuleWithNullsafeVariant(): void ]); } + public function testMaybeArray(): void + { + $this->analyse([__DIR__ . '/data/array-destructuring-maybe-array.php'], [ + [ + 'Cannot use array destructuring on array|false.', + 13, + ], + [ + 'Offset 0 might not exist on array{0?: int, 1: int}.', + 13, + ], + [ + 'Cannot use array destructuring on list|false.', + 21, + ], + [ + 'Cannot use array destructuring on list>|false.', + 29, + ], + [ + 'Offset 0 might not exist on array{0?: int}.', + 29, + ], + [ + 'Cannot use array destructuring on ArrayAccess|false.', + 37, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 0724cb09adf..0d4ee6b08fe 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1444,4 +1444,54 @@ public function testUnsealedArrayShapes(bool $reportPossiblyNonexistentGeneralAr $this->analyse([__DIR__ . '/data/unsealed-array-shapes-has-offset.php'], $expectedErrors); } + public function testOffsetOnMaybeAccessible(): void + { + $this->analyse([__DIR__ . '/data/offset-on-maybe-accessible.php'], [ + [ + 'Cannot access offset \'host\' on array{host?: string}|false.', + 13, + ], + [ + 'Offset \'host\' might not exist on array{host?: string}|false.', + 13, + ], + [ + 'Cannot access offset \'host\' on array{host: string}|false.', + 21, + ], + [ + 'Cannot access offset \'host\' on array{host?: string}|int.', + 29, + ], + [ + 'Offset \'host\' might not exist on array{host?: string}|int.', + 29, + ], + [ + 'Cannot access offset \'host\' on array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|false.', + 35, + ], + [ + 'Offset \'host\' might not exist on array{scheme?: string, host?: string, port?: int<0, 65535>, user?: string, pass?: string, path?: string, query?: string, fragment?: string}|false.', + 35, + ], + [ + 'Offset \'host\' might not exist on array{host?: string}.', + 47, + ], + [ + 'Cannot access offset \'host\' on array{host?: string}|false.', + 55, + ], + [ + 'Cannot access offset \'host\' on array{host: string}|stdClass.', + 67, + ], + [ + 'Cannot access offset \'host\' on array{host: string}|iterable.', + 75, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/array-destructuring-maybe-array.php b/tests/PHPStan/Rules/Arrays/data/array-destructuring-maybe-array.php new file mode 100644 index 00000000000..bf5dc8c5d6b --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/array-destructuring-maybe-array.php @@ -0,0 +1,40 @@ +|false $value + */ + public function arrayAccessOrFalse($value): void + { + [$a] = $value; + } + +} diff --git a/tests/PHPStan/Rules/Arrays/data/offset-on-maybe-accessible.php b/tests/PHPStan/Rules/Arrays/data/offset-on-maybe-accessible.php new file mode 100644 index 00000000000..cfafcd6a43f --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/offset-on-maybe-accessible.php @@ -0,0 +1,78 @@ + $value + */ + public function requiredKeyOrIterable($value): void + { + echo $value['host']; + } + +}