|
141 | 141 | use PHPStan\TrinaryLogic; |
142 | 142 | use PHPStan\Type\ArrayType; |
143 | 143 | use PHPStan\Type\ClosureType; |
| 144 | +use PHPStan\Type\Constant\ConstantIntegerType; |
| 145 | +use PHPStan\Type\Constant\ConstantStringType; |
144 | 146 | use PHPStan\Type\FileTypeMapper; |
145 | 147 | use PHPStan\Type\Generic\TemplateTypeHelper; |
146 | 148 | use PHPStan\Type\Generic\TemplateTypeMap; |
@@ -4062,6 +4064,14 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto |
4062 | 4064 | )->getScope(); |
4063 | 4065 | $vars = array_merge($vars, $this->getAssignedVariables($stmt->keyVar)); |
4064 | 4066 | } |
| 4067 | + |
| 4068 | + if ($stmt->valueVar instanceof List_) { |
| 4069 | + $scope = $this->addDestructureTaggedUnionConditionalHolders( |
| 4070 | + $scope, |
| 4071 | + $originalScope->getIterableValueType($iterateeType), |
| 4072 | + $stmt->valueVar, |
| 4073 | + ); |
| 4074 | + } |
4065 | 4075 | } |
4066 | 4076 |
|
4067 | 4077 | $constantArrays = $iterateeType->getConstantArrays(); |
@@ -4120,6 +4130,115 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto |
4120 | 4130 | return $this->processVarAnnotation($scope, $vars, $stmt); |
4121 | 4131 | } |
4122 | 4132 |
|
| 4133 | + /** |
| 4134 | + * When destructuring an iterable whose value type is a tagged union of |
| 4135 | + * constant arrays — e.g. `array<array{null, int}|array{int, null}>` — the |
| 4136 | + * variants describe a relationship between the destructured variables that |
| 4137 | + * a per-variable narrowing would normally lose: knowing `$x === null` should |
| 4138 | + * imply `$y === int`, but `foreach ($a as [$x, $y])` assigns `$x` and `$y` |
| 4139 | + * independently, so each ends up as the union (`int|null`) and the link is |
| 4140 | + * dropped. |
| 4141 | + * |
| 4142 | + * Recover the link by storing conditional-expression holders on each |
| 4143 | + * destructured variable: for every variant, "when this variable matches the |
| 4144 | + * variant's value at its position, the other variables match the variant's |
| 4145 | + * values at their positions". A later `if ($x === null)` then fires the |
| 4146 | + * matching holder and narrows `$y` accordingly. |
| 4147 | + * |
| 4148 | + * Only handles flat positional / keyed destructure patterns (List_) where |
| 4149 | + * each item's target is a plain Variable; nested destructure is left for |
| 4150 | + * the regular per-variable type tracking. |
| 4151 | + */ |
| 4152 | + private function addDestructureTaggedUnionConditionalHolders( |
| 4153 | + MutatingScope $scope, |
| 4154 | + Type $iterableValueType, |
| 4155 | + List_ $list, |
| 4156 | + ): MutatingScope |
| 4157 | + { |
| 4158 | + $constantArrays = $iterableValueType->getConstantArrays(); |
| 4159 | + if (count($constantArrays) < 2) { |
| 4160 | + return $scope; |
| 4161 | + } |
| 4162 | + |
| 4163 | + // Collect each list item's array-key value and target variable. |
| 4164 | + $items = []; |
| 4165 | + foreach ($list->items as $position => $item) { |
| 4166 | + if ($item === null) { |
| 4167 | + continue; |
| 4168 | + } |
| 4169 | + if (!$item->value instanceof Variable || !is_string($item->value->name)) { |
| 4170 | + return $scope; |
| 4171 | + } |
| 4172 | + if ($item->key === null) { |
| 4173 | + $keyValue = $position; |
| 4174 | + } elseif ($item->key instanceof Node\Scalar\String_) { |
| 4175 | + $keyValue = $item->key->value; |
| 4176 | + } elseif ($item->key instanceof Node\Scalar\Int_) { |
| 4177 | + $keyValue = $item->key->value; |
| 4178 | + } else { |
| 4179 | + return $scope; |
| 4180 | + } |
| 4181 | + $items[] = ['key' => $keyValue, 'name' => $item->value->name]; |
| 4182 | + } |
| 4183 | + |
| 4184 | + if (count($items) < 2) { |
| 4185 | + return $scope; |
| 4186 | + } |
| 4187 | + |
| 4188 | + // For every variant, every item must have a matching key with a single |
| 4189 | + // value type at it; otherwise the variants don't all describe the same |
| 4190 | + // destructure shape and we can't form a sound holder set. |
| 4191 | + $variantValuesByItem = []; |
| 4192 | + foreach ($items as $itemIdx => $itemInfo) { |
| 4193 | + $variantValuesByItem[$itemIdx] = []; |
| 4194 | + foreach ($constantArrays as $variantIdx => $variant) { |
| 4195 | + $keyType = is_int($itemInfo['key']) ? new ConstantIntegerType($itemInfo['key']) : new ConstantStringType($itemInfo['key']); |
| 4196 | + if (!$variant->hasOffsetValueType($keyType)->yes()) { |
| 4197 | + return $scope; |
| 4198 | + } |
| 4199 | + $variantValuesByItem[$itemIdx][$variantIdx] = $variant->getOffsetValueType($keyType); |
| 4200 | + } |
| 4201 | + } |
| 4202 | + |
| 4203 | + // For each item × variant, build a holder: "when item is variant's value |
| 4204 | + // at this position, the *other* items are the variant's values at their |
| 4205 | + // positions". Skip the variant if the condition value is too wide to be |
| 4206 | + // a useful discriminator (i.e. equal to the union of all the variant |
| 4207 | + // values at this position — narrowing it back wouldn't pick a variant). |
| 4208 | + foreach ($items as $itemIdx => $itemInfo) { |
| 4209 | + $exprString = '$' . $itemInfo['name']; |
| 4210 | + $variantConditionTypes = $variantValuesByItem[$itemIdx]; |
| 4211 | + $itemUnionType = TypeCombinator::union(...array_values($variantConditionTypes)); |
| 4212 | + $holders = []; |
| 4213 | + foreach (array_keys($constantArrays) as $variantIdx) { |
| 4214 | + $conditionType = $variantConditionTypes[$variantIdx]; |
| 4215 | + if ($conditionType->equals($itemUnionType)) { |
| 4216 | + continue; |
| 4217 | + } |
| 4218 | + $conditions = [ |
| 4219 | + $exprString => ExpressionTypeHolder::createYes(new Variable($itemInfo['name']), $conditionType), |
| 4220 | + ]; |
| 4221 | + foreach ($items as $otherIdx => $otherInfo) { |
| 4222 | + if ($otherIdx === $itemIdx) { |
| 4223 | + continue; |
| 4224 | + } |
| 4225 | + $otherType = $variantValuesByItem[$otherIdx][$variantIdx]; |
| 4226 | + $holder = new ConditionalExpressionHolder( |
| 4227 | + $conditions, |
| 4228 | + ExpressionTypeHolder::createYes(new Variable($otherInfo['name']), $otherType), |
| 4229 | + ); |
| 4230 | + $holders['$' . $otherInfo['name']][$holder->getKey()] = $holder; |
| 4231 | + } |
| 4232 | + } |
| 4233 | + |
| 4234 | + foreach ($holders as $targetExprString => $targetHolders) { |
| 4235 | + $scope = $scope->addConditionalExpressions($targetExprString, $targetHolders); |
| 4236 | + } |
| 4237 | + } |
| 4238 | + |
| 4239 | + return $scope; |
| 4240 | + } |
| 4241 | + |
4123 | 4242 | /** |
4124 | 4243 | * @param callable(Node $node, Scope $scope): void $nodeCallback |
4125 | 4244 | */ |
|
0 commit comments