diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 4ce247488f..b941201351 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -62,6 +62,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Accessory\OversizedArrayType; @@ -3852,6 +3853,122 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope ); } + public function applyNarrowingsFromForeachUnroll(self $preLoopScope, self $unrolledScope): self + { + $expressionTypes = $this->expressionTypes; + $nativeTypes = $this->nativeExpressionTypes; + $changed = false; + + foreach ($unrolledScope->expressionTypes as $exprString => $holder) { + if (!isset($preLoopScope->expressionTypes[$exprString])) { + continue; + } + + if (!isset($expressionTypes[$exprString])) { + continue; + } + + $unrolledType = $holder->getType(); + $currentType = $expressionTypes[$exprString]->getType(); + + if ($unrolledType->equals($currentType)) { + continue; + } + + // Extract accessory types from the unrolled type that the current type doesn't have + $newAccessories = []; + foreach (TypeUtils::getAccessoryTypes($unrolledType) as $accessoryType) { + if (!($accessoryType instanceof HasOffsetValueType) && !($accessoryType instanceof HasOffsetType)) { + continue; + } + $found = false; + foreach (TypeUtils::getAccessoryTypes($currentType) as $currentAccessory) { + if ($accessoryType->equals($currentAccessory)) { + $found = true; + break; + } + } + if ($found) { + continue; + } + + $newAccessories[] = $accessoryType; + } + + if ($newAccessories === []) { + continue; + } + + $newType = TypeCombinator::intersect($currentType, ...$newAccessories); + + $expressionTypes[$exprString] = new ExpressionTypeHolder( + $holder->getExpr(), + $newType, + $expressionTypes[$exprString]->getCertainty(), + ); + $changed = true; + + if (!isset($unrolledScope->nativeExpressionTypes[$exprString]) || !isset($nativeTypes[$exprString])) { + continue; + } + + $unrolledNativeType = $unrolledScope->nativeExpressionTypes[$exprString]->getType(); + $currentNativeType = $nativeTypes[$exprString]->getType(); + + $nativeAccessories = []; + foreach (TypeUtils::getAccessoryTypes($unrolledNativeType) as $accessoryType) { + if (!($accessoryType instanceof HasOffsetValueType) && !($accessoryType instanceof HasOffsetType)) { + continue; + } + $found = false; + foreach (TypeUtils::getAccessoryTypes($currentNativeType) as $currentAccessory) { + if ($accessoryType->equals($currentAccessory)) { + $found = true; + break; + } + } + if ($found) { + continue; + } + + $nativeAccessories[] = $accessoryType; + } + + if ($nativeAccessories === []) { + continue; + } + + $nativeTypes[$exprString] = new ExpressionTypeHolder( + $holder->getExpr(), + TypeCombinator::intersect($currentNativeType, ...$nativeAccessories), + $nativeTypes[$exprString]->getCertainty(), + ); + } + + if (!$changed) { + return $this; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + [], + [], + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + public function generalizeWith(self $otherScope): self { $variableTypeHolders = $this->generalizeVariableTypeHolders( diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index ca1daa6f34..a43169fe4c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -1393,6 +1393,61 @@ public function processStmtNode( $finalScope = $finalScope->assignExpression(new ForeachValueByRefExpr($stmt->valueVar), new MixedType(), new MixedType()); } + if ( + $context->isTopLevel() + && $isIterableAtLeastOnce->yes() + && !$finalScopeResult->isAlwaysTerminating() + && !$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()) <= 32) { + $constArray = $constantArrays[0]; + $unrolledScope = $scope; + $allIterationsComplete = true; + + foreach ($constArray->getKeyTypes() as $i => $keyType) { + $valueType = $constArray->getValueTypes()[$i]; + $iterScope = $unrolledScope->assignVariable( + $stmt->valueVar->name, + $valueType, + $valueType, + TrinaryLogic::createYes(), + ); + if ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name)) { + $iterScope = $iterScope->assignVariable( + $stmt->keyVar->name, + $keyType, + $keyType, + TrinaryLogic::createYes(), + ); + } + + $unrollStorage = $originalStorage->duplicate(); + $iterResult = $this->processStmtNodesInternal( + $stmt, + $stmt->stmts, + $iterScope, + $unrollStorage, + new NoopNodeCallback(), + $context->enterDeep(), + )->filterOutLoopExitPoints(); + + if ($iterResult->isAlwaysTerminating()) { + $allIterationsComplete = false; + break; + } + + $unrolledScope = $iterResult->getScope(); + } + + if ($allIterationsComplete) { + $finalScope = $finalScope->applyNarrowingsFromForeachUnroll($scope, $unrolledScope); + } + } + } + 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..3257df3fb1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11533.php @@ -0,0 +1,43 @@ +&hasOffsetValue('field', string)&hasOffsetValue('need', string)", $param); +} + +/** @param array{need: string, field: string} $param */ +function world(array $param): void +{ +} + +/** @param mixed[] $param */ +function helloWorld(array $param): void +{ + foreach (['need', 'field'] as $field) { + if (!isset($param[$field]) || !is_string($param[$field])) { + throw new \Exception(); + } + } + world($param); +} + +/** @param mixed[] $param */ +function withKey(array $param): void +{ + foreach (['need', 'field'] as $key => $field) { + if (!isset($param[$field]) || !is_string($param[$field])) { + throw new \Exception(); + } + } + assertType("non-empty-array&hasOffsetValue('field', string)&hasOffsetValue('need', string)", $param); +}