diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ca1daa6f34..e6c946a251 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2719,6 +2719,7 @@ public function processClosureNode( $closureScope = $scope->enterAnonymousFunction($expr, $callableParameters); $closureScope = $closureScope->processClosureScope($scope, null, $byRefUses); + $closureScope = $this->applyArrayKeysSourceToScope($expr, $closureScope); $closureType = $closureScope->getAnonymousFunctionReflection(); if (!$closureType instanceof ClosureType) { throw new ShouldNotHappenException(); @@ -2881,6 +2882,7 @@ public function processArrowFunctionNode( $arrowFunctionCallArgs, $passedToType, )); + $arrowFunctionScope = $this->applyArrayKeysSourceToScope($expr, $arrowFunctionScope); $arrowFunctionType = $arrowFunctionScope->getAnonymousFunctionReflection(); if ($arrowFunctionType === null) { throw new ShouldNotHappenException(); @@ -3331,6 +3333,8 @@ public function processArgs( } } + $this->detectArrayKeysInSiblingArgs($args, $i, $arg->value); + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null); if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { @@ -3389,6 +3393,8 @@ public function processArgs( } } + $this->detectArrayKeysInSiblingArgs($args, $i, $arg->value); + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null); if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { @@ -3920,6 +3926,78 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto return $this->processVarAnnotation($scope, $vars, $stmt); } + private const ARRAY_KEYS_SOURCE_ATTRIBUTE = 'arrayKeysSourceExprs'; + + /** + * @param Arg[] $args + */ + private function detectArrayKeysInSiblingArgs(array $args, int $currentIndex, Expr $callbackExpr): void + { + $arrayKeysSourceExprs = []; + foreach ($args as $j => $otherArg) { + if ($j === $currentIndex) { + continue; + } + if ( + !($otherArg->value instanceof FuncCall) + || !($otherArg->value->name instanceof Name) + || $otherArg->value->name->toLowerString() !== 'array_keys' + ) { + continue; + } + + $funcArgs = $otherArg->value->getArgs(); + if (count($funcArgs) !== 1) { + continue; + } + + $arrayKeysSourceExprs[] = $funcArgs[0]->value; + } + if ($arrayKeysSourceExprs === []) { + return; + } + + $callbackExpr->setAttribute(self::ARRAY_KEYS_SOURCE_ATTRIBUTE, $arrayKeysSourceExprs); + } + + private function applyArrayKeysSourceToScope(Expr $callbackExpr, MutatingScope $scope): MutatingScope + { + /** @var Expr[]|null $arrayKeysSourceExprs */ + $arrayKeysSourceExprs = $callbackExpr->getAttribute(self::ARRAY_KEYS_SOURCE_ATTRIBUTE); + if ($arrayKeysSourceExprs === null) { + return $scope; + } + + $params = []; + if ($callbackExpr instanceof Expr\ArrowFunction || $callbackExpr instanceof Expr\Closure) { + $params = $callbackExpr->params; + } + + foreach ($arrayKeysSourceExprs as $sourceExpr) { + $sourceType = $scope->getType($sourceExpr); + $sourceNativeType = $scope->getNativeType($sourceExpr); + $keyType = $sourceType->getIterableKeyType(); + + foreach ($params as $param) { + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + continue; + } + $paramType = $scope->getVariableType($param->var->name); + if (!$keyType->isSuperTypeOf($paramType)->yes()) { + continue; + } + + $scope = $scope->assignExpression( + new ArrayDimFetch($sourceExpr, $param->var), + $sourceType->getIterableValueType(), + $sourceNativeType->getIterableValueType(), + ); + } + } + + return $scope; + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 00783f481b..e12845a36f 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1263,4 +1263,11 @@ public function testBug14308(): void $this->analyse([__DIR__ . '/data/bug-14308.php'], []); } + public function testBug14265(): void + { + $this->reportPossiblyNonexistentConstantArrayOffset = true; + + $this->analyse([__DIR__ . '/data/bug-14265.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Arrays/data/bug-14265.php b/tests/PHPStan/Rules/Arrays/data/bug-14265.php new file mode 100644 index 0000000000..5e89abc113 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-14265.php @@ -0,0 +1,23 @@ + '1', + ]; + if (!empty($someVar)) { + $a['k2'] = '1'; + } + $b = array_reduce( + array_keys($a), + fn($carry, $key) => $carry . ' ' . $key . '="' . htmlspecialchars($a[$key]) . '"', + '' + ); + + return $b; +}