Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down
55 changes: 55 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -1393,6 +1393,61 @@
$finalScope = $finalScope->assignExpression(new ForeachValueByRefExpr($stmt->valueVar), new MixedType(), new MixedType());
}

if (
$context->isTopLevel()
&& $isIterableAtLeastOnce->yes()

Check warning on line 1398 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $context->isTopLevel() - && $isIterableAtLeastOnce->yes() + && !$isIterableAtLeastOnce->no() && !$finalScopeResult->isAlwaysTerminating() && !$stmt->byRef && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)

Check warning on line 1398 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $context->isTopLevel() - && $isIterableAtLeastOnce->yes() + && !$isIterableAtLeastOnce->no() && !$finalScopeResult->isAlwaysTerminating() && !$stmt->byRef && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)
&& !$finalScopeResult->isAlwaysTerminating()
&& !$stmt->byRef
&& $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)
&& $exprType->isConstantArray()->yes()

Check warning on line 1402 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && !$finalScopeResult->isAlwaysTerminating() && !$stmt->byRef && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name) - && $exprType->isConstantArray()->yes() + && !$exprType->isConstantArray()->no() ) { $constantArrays = $exprType->getConstantArrays(); if (count($constantArrays) === 1 && count($constantArrays[0]->getValueTypes()) <= 32) {

Check warning on line 1402 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && !$finalScopeResult->isAlwaysTerminating() && !$stmt->byRef && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name) - && $exprType->isConstantArray()->yes() + && !$exprType->isConstantArray()->no() ) { $constantArrays = $exprType->getConstantArrays(); if (count($constantArrays) === 1 && count($constantArrays[0]->getValueTypes()) <= 32) {
) {
$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(),
Expand Down
43 changes: 43 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-11533.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
<?php declare(strict_types = 1);

namespace Bug11533;

use function PHPStan\Testing\assertType;

/** @param mixed[] $param */
function hello(array $param): void
{
foreach (['need', 'field'] as $field) {
if (!isset($param[$field]) || !is_string($param[$field])) {
throw new \Exception();
}
}
assertType("non-empty-array<mixed>&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<mixed>&hasOffsetValue('field', string)&hasOffsetValue('need', string)", $param);
}
Loading