diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 8935ade8e2..ab397361ff 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -137,6 +137,7 @@ use PHPStan\Rules\Properties\ReadWritePropertiesExtensionProvider; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\ArrayType; use PHPStan\Type\ClosureType; use PHPStan\Type\FileTypeMapper; @@ -1393,6 +1394,85 @@ public function processStmtNode( $finalScope = $finalScope->assignExpression(new ForeachValueByRefExpr($stmt->valueVar), new MixedType(), new MixedType()); } + // Propagate per-element type narrowings from foreach over constant arrays + if ( + $context->isTopLevel() + && count($breakExitPoints) === 0 + && $isIterableAtLeastOnce->yes() + && !$stmt->byRef + && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name) + && $exprType->isConstantArray()->yes() + ) { + $constantArrays = $exprType->getConstantArrays(); + if ( + count($constantArrays) === 1 + && count($constantArrays[0]->getValueTypes()) > 0 + && count($constantArrays[0]->getValueTypes()) <= 32 + ) { + $constantArray = $constantArrays[0]; + $offsetValueTypes = []; + foreach ($constantArray->getValueTypes() as $valueType) { + $constantStrings = $valueType->getConstantStrings(); + if (count($constantStrings) === 1) { + $offsetValueTypes[] = $constantStrings[0]; + continue; + } + $offsetValueTypes = []; + break; + } + + if (count($offsetValueTypes) > 0) { + $bodyEndScope = $finalScopeResult->getScope(); + $loopVar = $stmt->valueVar; + foreach ($finalScope->getDefinedVariables() as $varName) { + if ($varName === $loopVar->name) { + continue; + } + $varExpr = new Variable($varName); + $varType = $finalScope->getType($varExpr); + if (!$varType->isArray()->yes()) { + continue; + } + + // Skip if the variable was modified (assigned) in the body + $preLoopVarType = $scope->getType($varExpr); + if (!$preLoopVarType->equals($varType)) { + continue; + } + + $dimFetch = new ArrayDimFetch($varExpr, $loopVar); + // Only proceed if the body specifically narrowed $var[$field] + if (!$bodyEndScope->hasExpressionType($dimFetch)->yes()) { + continue; + } + // Skip if the pre-loop scope already had this expression type + if ($scope->hasExpressionType($dimFetch)->yes()) { + continue; + } + + $dimFetchType = $bodyEndScope->getType($dimFetch); + $genericValueType = $varType->getIterableValueType(); + + if ($dimFetchType->equals($genericValueType)) { + continue; + } + + $accessories = []; + foreach ($offsetValueTypes as $offsetType) { + $accessories[] = new HasOffsetValueType($offsetType, $dimFetchType); + } + $narrowedVarType = TypeCombinator::intersect($varType, ...$accessories); + $finalScope = $finalScope->assignVariable( + $varName, + $narrowedVarType, + TypeCombinator::intersect($finalScope->getNativeType($varExpr), ...$accessories), + TrinaryLogic::createYes(), + ); + } + } + } + } + return new InternalStatementResult( $finalScope, $finalScopeResult->hasYield() || $condResult->hasYield(), diff --git a/tests/PHPStan/Analyser/nsrt/bug-11533.php b/tests/PHPStan/Analyser/nsrt/bug-11533.php new file mode 100644 index 0000000000..25ce661030 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11533.php @@ -0,0 +1,27 @@ +&hasOffsetValue('field', string)&hasOffsetValue('need', string)", $param); +} + +/** @param array $data */ +function helloWithArrayKeyExists(array $data): void +{ + foreach (['name', 'email'] as $key) { + if (!array_key_exists($key, $data) || !is_string($data[$key])) { + throw new \Exception(); + } + } + assertType("non-empty-array&hasOffsetValue('email', string)&hasOffsetValue('name', string)", $data); +}