diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index 2ba5323bf7b..aabb5aedbce 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -352,6 +352,15 @@ static function () use ($condResult, $emptyArrayType): Type { if ($unrolledEndScope !== null) { $finalScope = $unrolledEndScope; + } elseif (count($breakExitPoints) === 0) { + $backEdgeScopes = []; + if ($finalScopeResult->isEndReachable()) { + $backEdgeScopes[] = $finalScopeResult->getScope(); + } + foreach ($finalScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $backEdgeScopes[] = $continueExitPoint->getScope(); + } + $finalScope = ForeachIterationOffsetWritesHelper::makeWrittenOffsetsRequired($nodeScopeResolver, $stmt, $finalScope, $backEdgeScopes, $foreachIterateeType, $foreachNativeIterateeType, $finalScopeResult->getImpurePoints()); } // $scope is the post-loop scope; the body may have modified the iteratee diff --git a/src/Analyser/StmtHandler/ForeachIterationOffsetWritesHelper.php b/src/Analyser/StmtHandler/ForeachIterationOffsetWritesHelper.php new file mode 100644 index 00000000000..19af65e0da8 --- /dev/null +++ b/src/Analyser/StmtHandler/ForeachIterationOffsetWritesHelper.php @@ -0,0 +1,395 @@ + $backEdgeScopes the scopes the body continues to the next iteration with + * @param array $bodyImpurePoints + */ + public static function makeWrittenOffsetsRequired( + NodeScopeResolver $nodeScopeResolver, + Foreach_ $stmt, + MutatingScope $finalScope, + array $backEdgeScopes, + Type $iterateeType, + Type $nativeIterateeType, + array $bodyImpurePoints, + ): MutatingScope + { + if ($backEdgeScopes === [] || $stmt->byRef) { + return $finalScope; + } + + if ( + self::getIteratedOffsets($iterateeType, true) === null + && self::getIteratedOffsets($iterateeType, false) === null + ) { + return $finalScope; + } + + $candidates = self::findCandidates($stmt); + if ($candidates === []) { + return $finalScope; + } + + $canModifyProperties = null; + foreach ($candidates as [$target, $dimName, $isKey]) { + if ($target instanceof PropertyFetch) { + // a called method or function can change the property behind the scope's back + $canModifyProperties ??= self::canModifyProperties($bodyImpurePoints); + if ($canModifyProperties) { + continue; + } + } + + $offsets = self::getIteratedOffsets($iterateeType, $isKey); + if ($offsets === null) { + continue; + } + + $originalExpr = $isKey ? new OriginalForeachKeyExpr($dimName) : new OriginalForeachValueExpr($dimName); + $dimFetch = new ArrayDimFetch($target, new Variable($dimName)); + foreach ($backEdgeScopes as $backEdgeScope) { + if ( + !$backEdgeScope->hasExpressionType($originalExpr)->yes() + || !$backEdgeScope->hasExpressionType($dimFetch)->yes() + ) { + continue 2; + } + } + + $certainty = $finalScope->hasExpressionType($target); + if ($certainty->no()) { + continue; + } + + $targetType = self::makeOffsetsRequired($nodeScopeResolver->readScopeStateOrSyntheticType($target, $finalScope), $offsets); + if ($targetType === null) { + continue; + } + + $targetNativeType = $nodeScopeResolver->readScopeStateOrSyntheticType($target, $finalScope->doNotTreatPhpDocTypesAsCertain()); + $nativeOffsets = self::getIteratedOffsets($nativeIterateeType, $isKey); + if ($nativeOffsets !== null) { + $targetNativeType = self::makeOffsetsRequired($targetNativeType, $nativeOffsets) ?? $targetNativeType; + } + + $finalScope = $finalScope->specifyExpressionType($target, $targetType, $targetNativeType, $certainty); + } + + return $finalScope; + } + + /** + * @param array $impurePoints + */ + private static function canModifyProperties(array $impurePoints): bool + { + foreach ($impurePoints as $impurePoint) { + if (in_array($impurePoint->getIdentifier(), self::PROPERTY_MODIFYING_IMPURE_POINTS, true)) { + return true; + } + } + + return false; + } + + /** + * @param list $offsets + */ + private static function makeOffsetsRequired(Type $type, array $offsets): ?Type + { + if (!$type->isConstantArray()->yes()) { + return null; + } + + foreach ($offsets as $offset) { + $type = TypeCombinator::intersect($type, new HasOffsetType($offset)); + } + + if ($type instanceof NeverType) { + return null; + } + + return $type; + } + + /** + * The offsets the iterations write - each iteration's key or value of a + * sealed constant array with no optional keys, so every one is iterated. + * + * @return list|null + */ + private static function getIteratedOffsets(Type $iterateeType, bool $isKey): ?array + { + $constantArrays = $iterateeType->getConstantArrays(); + if (count($constantArrays) !== 1 || !$iterateeType->isConstantArray()->yes()) { + return null; + } + + $constantArray = $constantArrays[0]; + if ( + count($constantArray->getKeyTypes()) === 0 + || count($constantArray->getOptionalKeys()) > 0 + || !$constantArray->isUnsealed()->no() + ) { + return null; + } + + $offsets = []; + foreach ($isKey ? $constantArray->getKeyTypes() : $constantArray->getValueTypes() as $type) { + $values = $type->toArrayKey()->getConstantScalarValues(); + if (count($values) !== 1) { + continue; + } + $value = $values[0]; + if (is_int($value)) { + $offsets[] = new ConstantIntegerType($value); + } elseif (is_string($value)) { + $offsets[] = new ConstantStringType($value); + } + } + + if ($offsets === []) { + return null; + } + + return $offsets; + } + + /** + * The variables and `$this` properties the body writes as `$target[$keyOrValue] = ...` and + * otherwise accesses only through their offsets, not unsetting any. + * + * @return list target, key or value variable name, whether it is the key + */ + private static function findCandidates(Foreach_ $stmt): array + { + $valueName = $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name) ? $stmt->valueVar->name : null; + $keyName = $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ? $stmt->keyVar->name : null; + if ($valueName === null && $keyName === null) { + return []; + } + + /** @var array}> $writes */ + $writes = []; + $dimRootIds = []; + $nonOffsetKeys = []; + $hasDynamicThisProperty = false; + $nodes = $stmt->stmts; + while ($nodes !== []) { + $node = array_pop($nodes); + if (!$node instanceof Node) { + continue; + } + if ( + ($node instanceof Variable && !is_string($node->name)) + || $node instanceof Expr\Include_ + || $node instanceof Expr\Eval_ + || ( + $node instanceof Expr\FuncCall + && $node->name instanceof Node\Name + && in_array($node->name->toLowerString(), ['extract', 'parse_str'], true) + ) + ) { + return []; + } + + if ($node instanceof ArrayDimFetch) { + $dimRootIds[spl_object_id($node->var)] = true; + } + + if ($node instanceof Stmt\Unset_) { + foreach ($node->vars as $var) { + while ($var instanceof ArrayDimFetch) { + $var = $var->var; + } + $key = self::getTargetKey($var); + if ($key === null) { + continue; + } + + $nonOffsetKeys[$key] = true; + } + } + + if ($node instanceof Expr\Assign || $node instanceof Expr\AssignOp || $node instanceof Expr\AssignRef) { + foreach (self::getWriteTargets($node->var) as $target) { + while ($target->var instanceof ArrayDimFetch) { + $target = $target->var; + } + $key = self::getTargetKey($target->var); + if ( + $key === null + || !$target->dim instanceof Variable + || !is_string($target->dim->name) + ) { + continue; + } + + $dimName = $target->dim->name; + if ($dimName !== $valueName && $dimName !== $keyName) { + continue; + } + + $writes[$key] ??= [self::createTargetExpr($target->var), []]; + $writes[$key][1][$dimName] = $dimName !== $valueName; + } + } + + if ( + $node instanceof PropertyFetch + && $node->var instanceof Variable + && $node->var->name === 'this' + && !$node->name instanceof Node\Identifier + ) { + $hasDynamicThisProperty = true; + } + + $key = $node instanceof Expr ? self::getTargetKey($node) : null; + if ($key !== null && !isset($dimRootIds[spl_object_id($node)])) { + $nonOffsetKeys[$key] = true; + } + + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->{$subNodeName}; + if (is_array($subNode)) { + foreach ($subNode as $item) { + $nodes[] = $item; + } + } else { + $nodes[] = $subNode; + } + } + } + + $candidates = []; + foreach ($writes as $key => [$target, $dims]) { + if (isset($nonOffsetKeys[$key])) { + continue; + } + if ($target instanceof PropertyFetch && $hasDynamicThisProperty) { + continue; + } + if ($target instanceof Variable && ($target->name === $valueName || $target->name === $keyName)) { + continue; + } + foreach ($dims as $dimName => $isKey) { + $candidates[] = [$target, $dimName, $isKey]; + } + } + + return $candidates; + } + + /** + * A local variable or a property of `$this` - the write targets whose + * every access can be found in the loop body. + */ + private static function getTargetKey(Expr $expr): ?string + { + if ($expr instanceof Variable && is_string($expr->name) && $expr->name !== 'this') { + return '$' . $expr->name; + } + if ( + $expr instanceof PropertyFetch + && $expr->var instanceof Variable + && $expr->var->name === 'this' + && $expr->name instanceof Node\Identifier + ) { + return '$this->' . $expr->name->toString(); + } + + return null; + } + + private static function createTargetExpr(Expr $expr): Variable|PropertyFetch + { + if ($expr instanceof PropertyFetch && $expr->name instanceof Node\Identifier) { + return new PropertyFetch(new Variable('this'), new Node\Identifier($expr->name->toString())); + } + if ($expr instanceof Variable && is_string($expr->name)) { + return new Variable($expr->name); + } + + throw new ShouldNotHappenException(); + } + + /** + * @return list + */ + private static function getWriteTargets(Expr $target): array + { + if ($target instanceof ArrayDimFetch) { + return [$target]; + } + if (!$target instanceof Expr\List_ && !$target instanceof Expr\Array_) { + return []; + } + + $targets = []; + foreach ($target->items as $item) { + if ($item === null) { + continue; + } + foreach (self::getWriteTargets($item->value) as $itemTarget) { + $targets[] = $itemTarget; + } + } + + return $targets; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-15657.php b/tests/PHPStan/Analyser/nsrt/bug-15657.php new file mode 100644 index 00000000000..000d084fd6c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15657.php @@ -0,0 +1,257 @@ + $meta + */ +function readProps(array $meta): string +{ + // 17 entries - with 16 entries, all keys are known to exist after the loop + $metaKeyToProp = [ + '_description' => 'description', + '_regular_price' => 'regular_price', + '_sale_price' => 'sale_price', + '_date_on_sale_from' => 'date_on_sale_from', + '_date_on_sale_to' => 'date_on_sale_to', + '_manage_stock' => 'manage_stock', + '_stock_status' => 'stock_status', + '_virtual' => 'virtual', + '_downloadable' => 'downloadable', + '_sku' => 'sku', + '_stock_quantity' => 'stock_quantity', + '_weight' => 'weight', + '_length' => 'length', + '_width' => 'width', + '_height' => 'height', + '_backorders' => 'backorders', + '_tax_class' => 'tax_class', + ]; + + $props = []; + foreach ($metaKeyToProp as $metaKey => $prop) { + $props[$prop] = $meta[$metaKey] ?? ''; + } + + assertType('array{description: string, regular_price: string, sale_price: string, date_on_sale_from: string, date_on_sale_to: string, manage_stock: string, stock_status: string, virtual: string, downloadable: string, sku: string, stock_quantity: string, weight: string, length: string, width: string, height: string, backorders: string, tax_class: string}', $props); + + return $props['tax_class']; +} + +const MAP = ['a' => 1, 'b' => 2, 'c' => 3, 'd' => 4, 'e' => 5, 'f' => 6, 'g' => 7, 'h' => 8, 'i' => 9, 'j' => 10, 'k' => 11, 'l' => 12, 'm' => 13, 'n' => 14, 'o' => 15, 'p' => 16, 'q' => 17]; + +function keyVar(): void +{ + $result = []; + foreach (MAP as $key => $value) { + $result[$key] = $value * 2; + } + assertType('array{a: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, b: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, c: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, d: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, e: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, f: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, g: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, h: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, i: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, j: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, k: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, l: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, m: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, n: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, o: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, p: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34, q: 2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34}', $result); + assertType('2|4|6|8|10|12|14|16|18|20|22|24|26|28|30|32|34', $result['q']); +} + +function valueVarIntKeys(): void +{ + $result = []; + foreach (MAP as $key => $value) { + $result[$value] = $key; + } + assertType("array{1: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 2: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 3: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 4: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 5: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 6: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 7: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 8: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 9: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 10: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 11: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 12: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 13: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 14: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 15: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 16: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q', 17: 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'|'n'|'o'|'p'|'q'}", $result); +} + +function withoutKeyVar(): void +{ + $result = ['x' => 0]; + foreach (MAP as $value) { + if ($value > 5) { + $result['big'] = true; + } + $result[$value] = true; + } + assertType('true', array_key_exists(17, $result)); + assertType('0', $result['x']); + assertType('true', $result['big'] ?? true); +} + +function continueBeforeWrite(): void +{ + $result = []; + foreach (MAP as $key => $value) { + if ($value > 5) { + continue; + } + $result[$key] = $value; + } + assertType('array{a?: 1|2|3|4|5, b?: 1|2|3|4|5, c?: 1|2|3|4|5, d?: 1|2|3|4|5, e?: 1|2|3|4|5, f?: 1|2|3|4|5, g?: 1|2|3|4|5, h?: 1|2|3|4|5, i?: 1|2|3|4|5, j?: 1|2|3|4|5, k?: 1|2|3|4|5, l?: 1|2|3|4|5, m?: 1|2|3|4|5, n?: 1|2|3|4|5, o?: 1|2|3|4|5, p?: 1|2|3|4|5, q?: 1|2|3|4|5}', $result); +} + +function conditionalWrite(bool $b): void +{ + $result = []; + foreach (MAP as $key => $value) { + if ($b) { + $result[$key] = $value; + } + } + assertType('array{a?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, b?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, c?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, d?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, e?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, f?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, g?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, h?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, i?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, j?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, k?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, l?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, m?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, n?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, o?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, p?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, q?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17}', $result); +} + +function writeThenBreak(): void +{ + $result = []; + foreach (MAP as $key => $value) { + $result[$key] = $value; + if ($value === 3) { + break; + } + } + assertType('1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|null', $result['q'] ?? null); +} + +function unsetOtherKey(): void +{ + $result = []; + foreach (MAP as $key => $value) { + $result[$key] = $value; + if ($value === 3) { + unset($result['a']); + } + } + assertType('1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|null', $result['a'] ?? null); +} + +function reassigned(): void +{ + $result = []; + foreach (MAP as $key => $value) { + $result = []; + $result[$key] = $value; + } + assertType('1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17|null', $result['a'] ?? null); +} + +function passedByReference(): void +{ + $result = []; + foreach (MAP as $key => $value) { + $result[$key] = $value; + takeByReference($result); + } + assertType('mixed', $result['a'] ?? null); +} + +/** + * @param array $a + */ +function takeByReference(array &$a): void +{ +} + +function loopVariableReassigned(): void +{ + $result = []; + foreach (MAP as $key => $value) { + $key = 'a'; + $result[$key] = $value; + } + assertType('array{a: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17}', $result); +} + +function otherWriteForms(): void +{ + $r = []; + foreach (MAP as $k => $v) { + $r[$k] ??= $v; + } + assertType('true', array_key_exists('q', $r)); + + $r = []; + foreach (MAP as $k => $v) { + $r[$k][] = $v; + } + assertType('true', array_key_exists('q', $r)); + + $r = []; + foreach (MAP as $k => $v) { + $r[$k]['x'] = $v; + } + assertType('true', array_key_exists('q', $r)); + + $r = []; + foreach (MAP as $k => $v) { + $r[$k] = &$v; + } + assertType('true', array_key_exists('q', $r)); + + $r = []; + foreach (MAP as $k => $v) { + [$r[$k]] = [$v]; + } + assertType('true', array_key_exists('q', $r)); +} + +final class PropertyTarget +{ + + /** @var array */ + private array $props = []; + + public function writtenInEveryIteration(): void + { + $this->props = []; + foreach (MAP as $key => $value) { + $this->props[$key] = $value; + } + assertType('array{a: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, b: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, c: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, d: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, e: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, f: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, g: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, h: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, i: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, j: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, k: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, l: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, m: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, n: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, o: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, p: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, q: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17}', $this->props); + } + + public function resetAfterWrite(): void + { + $this->props = []; + foreach (MAP as $key => $value) { + $this->props[$key] = $value; + $this->reset(); + } + assertType('array', $this->props); + } + + public function reassignedAfterWrite(): void + { + $this->props = []; + foreach (MAP as $key => $value) { + $this->props[$key] = $value; + $this->props = []; + } + assertType('array{}', $this->props); + } + + public function reset(): void + { + $this->props = []; + } + +} + +final class ShapePropertyTarget +{ + + /** @var array{a?: int, b?: int, c?: int, d?: int, e?: int, f?: int, g?: int, h?: int, i?: int, j?: int, k?: int, l?: int, m?: int, n?: int, o?: int, p?: int, q?: int} */ + private array $props = []; + + public function resetBeforeWrite(): void + { + foreach (MAP as $key => $value) { + $this->reset(); + $this->props[$key] = $value; + } + assertType('non-empty-array{a?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, b?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, c?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, d?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, e?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, f?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, g?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, h?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, i?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, j?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, k?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, l?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, m?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, n?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, o?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, p?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17, q?: 1|2|3|4|5|6|7|8|9|10|11|12|13|14|15|16|17}', $this->props); + } + + public function reset(): void + { + $this->props = []; + } + +} diff --git a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php index 0724cb09adf..61a81f15a68 100644 --- a/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php @@ -1363,6 +1363,11 @@ public function testBug13688(): void $this->analyse([__DIR__ . '/data/bug-13688.php'], []); } + public function testBug15657(): void + { + $this->analyse([__DIR__ . '/data/bug-15657.php'], []); + } + public function testBug6379(): void { $this->analyse([__DIR__ . '/data/bug-6379.php'], []); diff --git a/tests/PHPStan/Rules/Arrays/data/bug-15657.php b/tests/PHPStan/Rules/Arrays/data/bug-15657.php new file mode 100644 index 00000000000..83d50a6cbc8 --- /dev/null +++ b/tests/PHPStan/Rules/Arrays/data/bug-15657.php @@ -0,0 +1,37 @@ + $meta + */ +function readProps(array $meta): string +{ + // 17 entries - with 16 entries, all keys are known to exist after the loop + $metaKeyToProp = [ + '_description' => 'description', + '_regular_price' => 'regular_price', + '_sale_price' => 'sale_price', + '_date_on_sale_from' => 'date_on_sale_from', + '_date_on_sale_to' => 'date_on_sale_to', + '_manage_stock' => 'manage_stock', + '_stock_status' => 'stock_status', + '_virtual' => 'virtual', + '_downloadable' => 'downloadable', + '_sku' => 'sku', + '_stock_quantity' => 'stock_quantity', + '_weight' => 'weight', + '_length' => 'length', + '_width' => 'width', + '_height' => 'height', + '_backorders' => 'backorders', + '_tax_class' => 'tax_class', + ]; + + $props = []; + foreach ($metaKeyToProp as $metaKey => $prop) { + $props[$prop] = $meta[$metaKey] ?? ''; + } + + return $props['tax_class']; +} diff --git a/turbo-ext/src/ForeachHandler.cpp b/turbo-ext/src/ForeachHandler.cpp index 93d6fd6990c..50cd21136ea 100644 --- a/turbo-ext/src/ForeachHandler.cpp +++ b/turbo-ext/src/ForeachHandler.cpp @@ -63,6 +63,7 @@ pt_property_site pt_feh_scalar_value_site; pt_method_site pt_feh_get_attributes_site; pt_method_site pt_feh_set_attributes_site; pt_method_site pt_feh_get_doc_comment_site; +pt_method_site pt_feh_make_written_offsets_required_site; /* the permanent interned strings of the twin's literals (module startup) */ zend_string *pt_feh_identical_narrowing_helper_class = nullptr; @@ -721,6 +722,8 @@ class ForeachHandler if (!unrolledEndScope.isNull()) { finalScope = zv::Val::copyOf(unrolledEndScope.ref()); + } else if (ptlh::countOf(breakExitPoints.raw()) == 0) { + if (UNEXPECTED(!makeWrittenOffsetsRequired(nodeScopeResolver, stmt, result, foreachIterateeType.raw(), foreachNativeIterateeType.raw(), finalScope))) return zv::Val(); } // $scope is the post-loop scope; the body may have modified the iteratee @@ -1224,6 +1227,39 @@ class ForeachHandler return true; } + /* ForeachIterationOffsetWritesHelper::makeWrittenOffsetsRequired($nodeScopeResolver, $stmt, + * $finalScope, $backEdgeScopes, $foreachIterateeType, $foreachNativeIterateeType, + * $finalScopeResult->getImpurePoints()) - the back-edge scopes are the end + * scope when reachable followed by the continue scopes */ + [[nodiscard]] bool makeWrittenOffsetsRequired(zval *nodeScopeResolver, zval *stmt, zval *result, zval *iterateeType, zval *nativeIterateeType, zv::Val &finalScope) const + { + zv::Arr backEdgeScopes = zv::Arr::empty(); + bool endReachable; + if (UNEXPECTED(!pt_internal_statement_result_is_end_reachable(result, endReachable))) return false; + if (endReachable) { + zv::Val hold; + zval *resultScope = pt_internal_statement_result_scope(result, hold); + if (UNEXPECTED(resultScope == NULL)) return false; + backEdgeScopes.push(zv::Ref(resultScope)); + } + zv::Val continues = ptlh::continueExitPoints(result); + if (UNEXPECTED(continues.isUndef())) return false; + for (auto entry : zv::ArrRef(continues.raw())) { + zv::Val hold; + zval *continueScope = pt_internal_statement_exit_point_scope(entry.value().deref().raw(), hold); + if (UNEXPECTED(continueScope == NULL)) return false; + backEdgeScopes.push(zv::Ref(continueScope)); + } + zv::Val impurePointsHold; + zval *impurePoints = pt_internal_statement_result_impure_points(result, impurePointsHold); + if (UNEXPECTED(impurePoints == NULL)) return false; + zv::Args argv{nodeScopeResolver, stmt, finalScope.raw(), backEdgeScopes.raw(), iterateeType, nativeIterateeType, impurePoints}; + zv::Val required = pt_call_static_cached(pt_feh_make_written_offsets_required_site, PT_CLASS_FOREACH_ITERATION_OFFSET_WRITES_HELPER, PT_LC("makewrittenoffsetsrequired"), 7, argv); + if (UNEXPECTED(required.isUndef())) return false; + finalScope = std::move(required); + return true; + } + /* $this->container->getByType(ForeachEmptyOrObjectNarrowingHelper::class) * ->narrowToEmptyOrObject($nodeScopeResolver, $scope, $stmt->expr, $condResult, $storage) */ zv::Val narrowToEmptyOrObject(zval *nodeScopeResolver, zval *scope, zval *iteratee, zval *iterateeResult, zval *storage) const diff --git a/turbo-ext/src/support.cpp b/turbo-ext/src/support.cpp index e34c9b87288..4559a4bf418 100644 --- a/turbo-ext/src/support.cpp +++ b/turbo-ext/src/support.cpp @@ -415,6 +415,7 @@ static const pt_class_template pt_class_templates[PT_CLASS_COUNT] = { /* PT_CLASS_NATIVE_PHP_TYPE_NODE_HELPER */ {"nativePhpTypeNodeHelper", "PHPStan\\Type\\NativePhpTypeNodeHelper"}, /* PT_CLASS_FULLY_QUALIFIED_NAME_NODE */ {"fullyQualifiedNameNode", "PHPStan\\PhpDocParser\\Ast\\Type\\FullyQualifiedNameNode"}, /* PT_CLASS_TYPE_NODE_DESCRIBER */ {"typeNodeDescriber", "PHPStan\\Type\\TypeNodeDescriber"}, + /* PT_CLASS_FOREACH_ITERATION_OFFSET_WRITES_HELPER */ {"foreachIterationOffsetWritesHelper", "PHPStan\\Analyser\\StmtHandler\\ForeachIterationOffsetWritesHelper"}, }; zend_class_entry *pt_class(int idx) diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index 6c0e26f9c3a..1f7f8b98845 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -519,6 +519,8 @@ enum { /* the class names of Type::toPhpDocNode() and how describe() prints them */ PT_CLASS_FULLY_QUALIFIED_NAME_NODE, PT_CLASS_TYPE_NODE_DESCRIBER, + /* the post-loop offsets of a foreach over a constant array that is not unrolled */ + PT_CLASS_FOREACH_ITERATION_OFFSET_WRITES_HELPER, PT_CLASS_COUNT };