Skip to content

Commit 1c2773e

Browse files
committed
Fix array_keys() offset access false positive in callbacks
- When array_keys($a) is passed as argument alongside a closure/arrow function, PHPStan now recognizes that $a[$key] is valid inside the callback - Extended the existing foreach(array_keys($a) as $key) special handling to also cover callbacks in function calls like array_reduce, array_map, etc. - New regression test in tests/PHPStan/Rules/Arrays/data/bug-14265.php Closes phpstan/phpstan#14265
1 parent cfba43c commit 1c2773e

3 files changed

Lines changed: 108 additions & 0 deletions

File tree

src/Analyser/NodeScopeResolver.php

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2719,6 +2719,7 @@ public function processClosureNode(
27192719

27202720
$closureScope = $scope->enterAnonymousFunction($expr, $callableParameters);
27212721
$closureScope = $closureScope->processClosureScope($scope, null, $byRefUses);
2722+
$closureScope = $this->applyArrayKeysSourceToScope($expr, $closureScope);
27222723
$closureType = $closureScope->getAnonymousFunctionReflection();
27232724
if (!$closureType instanceof ClosureType) {
27242725
throw new ShouldNotHappenException();
@@ -2881,6 +2882,7 @@ public function processArrowFunctionNode(
28812882
$arrowFunctionCallArgs,
28822883
$passedToType,
28832884
));
2885+
$arrowFunctionScope = $this->applyArrayKeysSourceToScope($expr, $arrowFunctionScope);
28842886
$arrowFunctionType = $arrowFunctionScope->getAnonymousFunctionReflection();
28852887
if ($arrowFunctionType === null) {
28862888
throw new ShouldNotHappenException();
@@ -3331,6 +3333,8 @@ public function processArgs(
33313333
}
33323334
}
33333335

3336+
$this->detectArrayKeysInSiblingArgs($args, $i, $arg->value);
3337+
33343338
$this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context);
33353339
$closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType ?? null);
33363340
if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) {
@@ -3389,6 +3393,8 @@ public function processArgs(
33893393
}
33903394
}
33913395

3396+
$this->detectArrayKeysInSiblingArgs($args, $i, $arg->value);
3397+
33923398
$this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context);
33933399
$arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null);
33943400
if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) {
@@ -3920,6 +3926,78 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
39203926
return $this->processVarAnnotation($scope, $vars, $stmt);
39213927
}
39223928

3929+
private const ARRAY_KEYS_SOURCE_ATTRIBUTE = 'arrayKeysSourceExprs';
3930+
3931+
/**
3932+
* @param Arg[] $args
3933+
*/
3934+
private function detectArrayKeysInSiblingArgs(array $args, int $currentIndex, Expr $callbackExpr): void
3935+
{
3936+
$arrayKeysSourceExprs = [];
3937+
foreach ($args as $j => $otherArg) {
3938+
if ($j === $currentIndex) {
3939+
continue;
3940+
}
3941+
if (
3942+
!($otherArg->value instanceof FuncCall)
3943+
|| !($otherArg->value->name instanceof Name)
3944+
|| $otherArg->value->name->toLowerString() !== 'array_keys'
3945+
) {
3946+
continue;
3947+
}
3948+
3949+
$funcArgs = $otherArg->value->getArgs();
3950+
if (count($funcArgs) !== 1) {
3951+
continue;
3952+
}
3953+
3954+
$arrayKeysSourceExprs[] = $funcArgs[0]->value;
3955+
}
3956+
if ($arrayKeysSourceExprs === []) {
3957+
return;
3958+
}
3959+
3960+
$callbackExpr->setAttribute(self::ARRAY_KEYS_SOURCE_ATTRIBUTE, $arrayKeysSourceExprs);
3961+
}
3962+
3963+
private function applyArrayKeysSourceToScope(Expr $callbackExpr, MutatingScope $scope): MutatingScope
3964+
{
3965+
/** @var Expr[]|null $arrayKeysSourceExprs */
3966+
$arrayKeysSourceExprs = $callbackExpr->getAttribute(self::ARRAY_KEYS_SOURCE_ATTRIBUTE);
3967+
if ($arrayKeysSourceExprs === null) {
3968+
return $scope;
3969+
}
3970+
3971+
$params = [];
3972+
if ($callbackExpr instanceof Expr\ArrowFunction || $callbackExpr instanceof Expr\Closure) {
3973+
$params = $callbackExpr->params;
3974+
}
3975+
3976+
foreach ($arrayKeysSourceExprs as $sourceExpr) {
3977+
$sourceType = $scope->getType($sourceExpr);
3978+
$sourceNativeType = $scope->getNativeType($sourceExpr);
3979+
$keyType = $sourceType->getIterableKeyType();
3980+
3981+
foreach ($params as $param) {
3982+
if (!$param->var instanceof Variable || !is_string($param->var->name)) {
3983+
continue;
3984+
}
3985+
$paramType = $scope->getVariableType($param->var->name);
3986+
if (!$keyType->isSuperTypeOf($paramType)->yes()) {
3987+
continue;
3988+
}
3989+
3990+
$scope = $scope->assignExpression(
3991+
new ArrayDimFetch($sourceExpr, $param->var),
3992+
$sourceType->getIterableValueType(),
3993+
$sourceNativeType->getIterableValueType(),
3994+
);
3995+
}
3996+
}
3997+
3998+
return $scope;
3999+
}
4000+
39234001
/**
39244002
* @param callable(Node $node, Scope $scope): void $nodeCallback
39254003
*/

tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1263,4 +1263,11 @@ public function testBug14308(): void
12631263
$this->analyse([__DIR__ . '/data/bug-14308.php'], []);
12641264
}
12651265

1266+
public function testBug14265(): void
1267+
{
1268+
$this->reportPossiblyNonexistentConstantArrayOffset = true;
1269+
1270+
$this->analyse([__DIR__ . '/data/bug-14265.php'], []);
1271+
}
1272+
12661273
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14265;
4+
5+
/**
6+
* @param mixed $someVar
7+
*/
8+
function doFoo($someVar): string
9+
{
10+
$a = [
11+
'k1' => '1',
12+
];
13+
if (!empty($someVar)) {
14+
$a['k2'] = '1';
15+
}
16+
$b = array_reduce(
17+
array_keys($a),
18+
fn($carry, $key) => $carry . ' ' . $key . '="' . htmlspecialchars($a[$key]) . '"',
19+
''
20+
);
21+
22+
return $b;
23+
}

0 commit comments

Comments
 (0)