Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
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
8 changes: 8 additions & 0 deletions src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -3348,6 +3348,14 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self
);
}

/**
* @return array<string, ConditionalExpressionHolder[]>
*/
public function getConditionalExpressions(): array
{
return $this->conditionalExpressions;
}

/**
* @param ConditionalExpressionHolder[] $conditionalExpressionHolders
*/
Expand Down
119 changes: 119 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@
use PHPStan\TrinaryLogic;
use PHPStan\Type\ArrayType;
use PHPStan\Type\ClosureType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\FileTypeMapper;
use PHPStan\Type\Generic\TemplateTypeHelper;
use PHPStan\Type\Generic\TemplateTypeMap;
Expand Down Expand Up @@ -4062,6 +4064,14 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
)->getScope();
$vars = array_merge($vars, $this->getAssignedVariables($stmt->keyVar));
}

if ($stmt->valueVar instanceof List_) {
$scope = $this->addDestructureTaggedUnionConditionalHolders(
$scope,
$originalScope->getIterableValueType($iterateeType),
$stmt->valueVar,
);
}
}

$constantArrays = $iterateeType->getConstantArrays();
Expand Down Expand Up @@ -4120,6 +4130,115 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto
return $this->processVarAnnotation($scope, $vars, $stmt);
}

/**
* When destructuring an iterable whose value type is a tagged union of
* constant arrays — e.g. `array<array{null, int}|array{int, null}>` — the
* variants describe a relationship between the destructured variables that
* a per-variable narrowing would normally lose: knowing `$x === null` should
* imply `$y === int`, but `foreach ($a as [$x, $y])` assigns `$x` and `$y`
* independently, so each ends up as the union (`int|null`) and the link is
* dropped.
*
* Recover the link by storing conditional-expression holders on each
* destructured variable: for every variant, "when this variable matches the
* variant's value at its position, the other variables match the variant's
* values at their positions". A later `if ($x === null)` then fires the
* matching holder and narrows `$y` accordingly.
*
* Only handles flat positional / keyed destructure patterns (List_) where
* each item's target is a plain Variable; nested destructure is left for
* the regular per-variable type tracking.
*/
private function addDestructureTaggedUnionConditionalHolders(
MutatingScope $scope,
Type $iterableValueType,
List_ $list,
): MutatingScope
{
$constantArrays = $iterableValueType->getConstantArrays();
if (count($constantArrays) < 2) {
return $scope;
}

// Collect each list item's array-key value and target variable.
$items = [];
foreach ($list->items as $position => $item) {
if ($item === null) {
continue;
}
if (!$item->value instanceof Variable || !is_string($item->value->name)) {
return $scope;
}
if ($item->key === null) {
$keyValue = $position;
} elseif ($item->key instanceof Node\Scalar\String_) {
$keyValue = $item->key->value;
} elseif ($item->key instanceof Node\Scalar\Int_) {
$keyValue = $item->key->value;
} else {
return $scope;
}
$items[] = ['key' => $keyValue, 'name' => $item->value->name];
}

if (count($items) < 2) {
return $scope;
}

// For every variant, every item must have a matching key with a single
// value type at it; otherwise the variants don't all describe the same
// destructure shape and we can't form a sound holder set.
$variantValuesByItem = [];
foreach ($items as $itemIdx => $itemInfo) {
$variantValuesByItem[$itemIdx] = [];
foreach ($constantArrays as $variantIdx => $variant) {
$keyType = is_int($itemInfo['key']) ? new ConstantIntegerType($itemInfo['key']) : new ConstantStringType($itemInfo['key']);
if (!$variant->hasOffsetValueType($keyType)->yes()) {
return $scope;
}
$variantValuesByItem[$itemIdx][$variantIdx] = $variant->getOffsetValueType($keyType);
}
}

// For each item × variant, build a holder: "when item is variant's value
// at this position, the *other* items are the variant's values at their
// positions". Skip the variant if the condition value is too wide to be
// a useful discriminator (i.e. equal to the union of all the variant
// values at this position — narrowing it back wouldn't pick a variant).
foreach ($items as $itemIdx => $itemInfo) {
$exprString = '$' . $itemInfo['name'];
$variantConditionTypes = $variantValuesByItem[$itemIdx];
$itemUnionType = TypeCombinator::union(...array_values($variantConditionTypes));
$holders = [];
foreach (array_keys($constantArrays) as $variantIdx) {
$conditionType = $variantConditionTypes[$variantIdx];
if ($conditionType->equals($itemUnionType)) {
continue;
}
$conditions = [
$exprString => ExpressionTypeHolder::createYes(new Variable($itemInfo['name']), $conditionType),
];
foreach ($items as $otherIdx => $otherInfo) {
if ($otherIdx === $itemIdx) {
continue;
}
$otherType = $variantValuesByItem[$otherIdx][$variantIdx];
$holder = new ConditionalExpressionHolder(
$conditions,
ExpressionTypeHolder::createYes(new Variable($otherInfo['name']), $otherType),
);
$holders['$' . $otherInfo['name']][$holder->getKey()] = $holder;
}
}

foreach ($holders as $targetExprString => $targetHolders) {
$scope = $scope->addConditionalExpressions($targetExprString, $targetHolders);
}
}

return $scope;
}

/**
* @param callable(Node $node, Scope $scope): void $nodeCallback
*/
Expand Down
77 changes: 77 additions & 0 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@
use PHPStan\Type\TypeTraverser;
use PHPStan\Type\UnionType;
use function array_key_exists;
use function array_key_first;
use function array_last;
use function array_map;
use function array_merge;
Expand Down Expand Up @@ -783,6 +784,7 @@ public function specifyTypesInCondition(
$types = $leftTypes->normalize($scope);
} else {
$types = $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($rightScope));
$types = $this->augmentBooleanOrTruthyWithConditionalHolders($scope, $rightScope, $expr, $types);
}
} else {
$types = $leftTypes->unionWith($rightTypes);
Expand Down Expand Up @@ -1941,6 +1943,81 @@ private function specifyTypesFromCallableCall(TypeSpecifierContext $context, Fun
return $this->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope);
}

/**
* For `if ($a || $b)` truthy, expressions narrowed by stored conditional
* holders (e.g. `$a = $obj instanceof ClassA;` records "when `$a` is
* truthy, `$obj` is `ClassA`") need to be projected into the OR-truthy
* scope as the union of the per-arm narrowings. specifyTypesInCondition
* for each arm only looks at the boolean variable itself, so the held
* narrowing of `$obj` would otherwise be invisible until a later check
* pins one of the booleans down.
*
* For each conditional-holder target $T:
* - resolve $T's type in the left-truthy and right-truthy filtered scopes
* - if both narrow $T strictly below the original, add `$T : leftT|rightT`
* as a sure type to the OR-truthy result
*
* The asymmetric case (one arm narrows, the other doesn't) is intentionally
* skipped: in the OR-truthy scope the arm that didn't narrow could still be
* the truthy one, so the sound result is the original (unnarrowed) type.
*/
private function augmentBooleanOrTruthyWithConditionalHolders(MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes
{
$leftTruthyScope = $scope->filterByTruthyValue($expr->left);
$rightTruthyScope = $rightScope->filterByTruthyValue($expr->right);

$seen = [];
foreach ([$scope, $rightScope] as $sourceScope) {
foreach ($sourceScope->getConditionalExpressions() as $exprString => $holders) {
if (isset($seen[$exprString])) {
continue;
}
if ($holders === []) {
continue;
}
$seen[$exprString] = true;
$targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr();

// Only project when the target stays Yes-defined in the original
// scope and in both filtered branches. A sure type implicitly
// raises certainty to Yes, which would wrongly upgrade Maybe-defined
// variables — `if (empty($a['bar']))` for instance leaves `$a`
// Maybe-defined because `empty()` tolerates undefined offsets.
if (!$scope->hasExpressionType($targetExpr)->yes()) {
continue;
}
if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) {
continue;
}
if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) {
continue;
}

$origType = $scope->getType($targetExpr);
$leftType = $leftTruthyScope->getType($targetExpr);
$rightType = $rightTruthyScope->getType($targetExpr);

$leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes();
$rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes();

if (!$leftNarrowed || !$rightNarrowed) {
continue;
}

$unionType = TypeCombinator::union($leftType, $rightType);
if ($unionType->equals($origType)) {
continue;
}

$types = $types->unionWith(
$this->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope),
);
}
}

return $types;
}

/**
* @return array<string, ConditionalExpressionHolder[]>
*/
Expand Down
64 changes: 64 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-9519.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php

namespace Bug9519;

use function PHPStan\Testing\assertType;

class ClassA {}
class ClassB {}

function instanceofVariants(mixed $obj): void
{
$isA = $obj instanceof ClassA;
$isB = $obj instanceof ClassB;

if ($isA || $isB) {
assertType('Bug9519\\ClassA|Bug9519\\ClassB', $obj);
}

// Sanity check: the equivalent inline form has always worked, so the
// stored-boolean form should produce the same narrowing.
if (($obj instanceof ClassA) || ($obj instanceof ClassB)) {
assertType('Bug9519\\ClassA|Bug9519\\ClassB', $obj);
}
}

/**
* Three-way OR over stored booleans — every arm narrows the same target.
*/
class ClassC {}

function threeWayInstanceof(mixed $obj): void
{
$isA = $obj instanceof ClassA;
$isB = $obj instanceof ClassB;
$isC = $obj instanceof ClassC;

if ($isA || $isB || $isC) {
assertType('Bug9519\\ClassA|Bug9519\\ClassB|Bug9519\\ClassC', $obj);
}
}

/**
* Different narrowing kinds across the OR's arms — `null !==` on the left,
* `instanceof` on the right.
*/
function mixedNarrowingKinds(?ClassA $a, mixed $b): void
{
$aNotNull = $a !== null;
$bIsB = $b instanceof ClassB;

if ($aNotNull || $bIsB) {
// Inside the truthy branch we don't know which arm fired, so each
// target keeps the union of (narrowed-when-its-arm-fired)
// and (original-when-the-other-arm-fired).
assertType(
'Bug9519\\ClassA|null',
$a,
);
assertType(
'mixed',
$b,
);
}
}
Loading
Loading