From 1aabbad82f4e22aa91c8ad0b64b532b3b1a09f4d Mon Sep 17 00:00:00 2001 From: Alfred Bez <1001186+alfredbez@users.noreply.github.com> Date: Sat, 10 Oct 2026 20:07:43 +0000 Subject: [PATCH 1/6] Reduce memory consumption of large literal arrays in 2.3.x --- src/Analyser/ExprHandler/ArrayHandler.php | 176 +++++++++++++----- src/Analyser/ExprHandler/AssignHandler.php | 2 +- src/Analyser/ExprHandler/ScalarHandler.php | 21 ++- src/Analyser/ExpressionResultStorage.php | 5 + .../DuplicateKeysInLiteralArraysRule.php | 38 +++- 5 files changed, 185 insertions(+), 57 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index ad28fe76aa0..594f55568a7 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -5,6 +5,7 @@ use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; +use PhpParser\Node\Scalar; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\FuncCall; @@ -24,6 +25,7 @@ use PHPStan\Analyser\VariableWriteOffset; use PHPStan\Dependency\Dependencies; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\LiteralArrayItem; use PHPStan\Node\LiteralArrayNode; use PHPStan\Node\Variable\VariableWrite; @@ -32,15 +34,15 @@ use PHPStan\Turbo\ShadowedByTurboExtension; use PHPStan\Type\ArrayType; use PHPStan\Type\CallableType; +use PHPStan\Type\Constant\ConstantFloatType; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\IntegerType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_key_exists; -use function array_map; use function array_merge; -use function array_values; use function count; use function is_int; use function max; @@ -70,8 +72,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; $itemNodes = []; - $itemResults = []; + $itemTypes = []; $variableFlows = []; + $dependencies = []; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -83,6 +86,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $passedToType = $this->getExpectedArrayType($context->getPassedToType()); $nativePassedToType = $this->getExpectedArrayType($context->getNativePassedToType()); $hasExpectedType = $passedToType !== null || $nativePassedToType !== null; + $hasArrayRef = self::hasArrayReference($expr); $nextIndex = 0; foreach ($expr->items as $arrayItem) { $itemNodes[] = new LiteralArrayItem($scope, $arrayItem); @@ -90,11 +94,26 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $keyResult = null; if ($arrayItem->key !== null) { $keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $scope, $storage, $nodeCallback, $context->enterDeepKeepingValueFlow()); - $itemResults[spl_object_id($arrayItem->key)] = $keyResult; - $variableFlows[] = $keyResult->getVariableFlow(); + if (!$arrayItem->key instanceof Scalar\String_ && !$arrayItem->key instanceof Scalar\Int_ && !$arrayItem->key instanceof Scalar\Float_) { + $itemTypes[spl_object_id($arrayItem->key)] = [$keyResult->getType(), $keyResult->getNativeType()]; + } + $keyDeps = $keyResult->getDependencies(); + if ($keyDeps !== null) { + $dependencies[] = $keyDeps; + } + $keyFlow = $keyResult->getVariableFlow(); + if ($keyFlow !== null) { + $variableFlows[] = $keyFlow; + } $hasYield = $hasYield || $keyResult->hasYield(); - $throwPoints = array_merge($throwPoints, $keyResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints()); + $keyThrow = $keyResult->getThrowPoints(); + if ($keyThrow !== []) { + $throwPoints = array_merge($throwPoints, $keyThrow); + } + $keyImpure = $keyResult->getImpurePoints(); + if ($keyImpure !== []) { + $impurePoints = array_merge($impurePoints, $keyImpure); + } $isAlwaysTerminating = $isAlwaysTerminating || $keyResult->isAlwaysTerminating(); $scope = $keyResult->getScope(); } @@ -134,24 +153,46 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); } $valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $valueContext); - $itemResults[spl_object_id($arrayItem->value)] = $valueResult; - $variableFlows[] = $valueResult->getVariableFlow(); + if (!$arrayItem->value instanceof Scalar\String_ && !$arrayItem->value instanceof Scalar\Int_ && !$arrayItem->value instanceof Scalar\Float_) { + $itemTypes[spl_object_id($arrayItem->value)] = [$valueResult->getType(), $valueResult->getNativeType()]; + } + $valDeps = $valueResult->getDependencies(); + if ($valDeps !== null) { + $dependencies[] = $valDeps; + } + $valFlow = $valueResult->getVariableFlow(); + if ($valFlow !== null) { + $variableFlows[] = $valFlow; + } if ($arrayItem->byRef) { $variableFlows[] = VariableFlowBuilder::escapeRoot($arrayItem->value); } $hasYield = $hasYield || $valueResult->hasYield(); - $throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints()); + $valThrow = $valueResult->getThrowPoints(); + if ($valThrow !== []) { + $throwPoints = array_merge($throwPoints, $valThrow); + } + $valImpure = $valueResult->getImpurePoints(); + if ($valImpure !== []) { + $impurePoints = array_merge($impurePoints, $valImpure); + } $isAlwaysTerminating = $isAlwaysTerminating || $valueResult->isAlwaysTerminating(); $scope = $valueResult->getScope(); // the item's callback fires after its key and value were processed, // with the item's entry scope - callback-side asks answer from the // storage instead of re-walking the yet-unstored sub-expressions $nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $itemCallbackScope, $storage); + + if (!$hasArrayRef) { + if ($arrayItem->key !== null) { + $storage->removeExpressionResult($arrayItem->key); + } + $storage->removeExpressionResult($arrayItem->value); + } } $nodeScopeResolver->callNodeCallback($nodeCallback, new LiteralArrayNode($expr, $itemNodes), $scope, $storage); if ($nodeScopeResolver->observingTemplateArgumentFrame($scope) !== null) { - $scope = $this->collectAbsorbedItems($expr, $itemResults, $scope); + $scope = $this->collectAbsorbedItems($expr, $itemTypes, $scope); } $result = $this->expressionResultFactory->create( @@ -163,16 +204,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - typeCallback: function (bool $nativeTypesPromoted) use ($expr, $itemResults, $beforeScope): Type { - // each item type was captured at its own evaluation point in the - // sequence - resolving all items on any single scope (the old world) - // cannot handle items with side effects like [$b = 1, $b + 1, $b++] - $type = $this->initializerExprTypeResolver->getArrayType($expr, static function (Expr $inner) use ($itemResults, $nativeTypesPromoted): Type { + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $itemTypes, $beforeScope): Type { + $type = $this->initializerExprTypeResolver->getArrayType($expr, static function (Expr $inner) use ($itemTypes, $nativeTypesPromoted): Type { + if ($inner instanceof Scalar\String_) { + return new ConstantStringType($inner->value); + } + if ($inner instanceof Scalar\Int_) { + return new ConstantIntegerType($inner->value); + } + if ($inner instanceof Scalar\Float_) { + return new ConstantFloatType($inner->value); + } + if ($inner instanceof TypeExpr) { + return $inner->getExprType(); + } $id = spl_object_id($inner); - if (array_key_exists($id, $itemResults)) { + if (array_key_exists($id, $itemTypes)) { return $nativeTypesPromoted - ? $itemResults[$id]->getNativeType() - : $itemResults[$id]->getType(); + ? $itemTypes[$id][1] + : $itemTypes[$id][0]; } throw new ShouldNotHappenException(); @@ -186,14 +236,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex new FullyQualified('is_callable'), [new Arg($expr)], ); - // isCallable() is asked last - it reflects the class named by the - // first item, which is expensive and unnecessary for arrays never - // narrowed by is_callable() if ( $beforeScope->hasExpressionType($isCallableCall)->yes() - // read the narrowed type from expressionTypes directly (the - // synthetic is_callable() call was never processed as a child), - // mirroring ConstFetchHandler's narrowed-constant lookup && $beforeScope->expressionTypes[$beforeScope->getNodeKey($isCallableCall)]->getType()->isTrue()->yes() && $type->isCallable()->maybe() ) { @@ -206,18 +250,20 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); - return $result->withDependencies(Dependencies::merge( - $this->getCallableDependencies($beforeScope, $expr, $itemResults, $result), - ...array_map(static fn (ExpressionResult $itemResult): ?Dependencies => $itemResult->getDependencies(), array_values($itemResults)), - )); + $callableDeps = $this->getCallableDependencies($beforeScope, $expr, $itemTypes, $result); + if ($callableDeps !== null) { + $dependencies[] = $callableDeps; + } + + return $result->withDependencies(Dependencies::merge(...$dependencies)); } /** * An array that may be a callable - `[Foo::class, 'method']` - depends on what calling it returns. * - * @param array $itemResults + * @param array $itemTypes */ - private function getCallableDependencies(MutatingScope $scope, Array_ $expr, array $itemResults, ExpressionResult $result): ?Dependencies + private function getCallableDependencies(MutatingScope $scope, Array_ $expr, array $itemTypes, ExpressionResult $result): ?Dependencies { if (count($expr->items) !== 2 || !isset($expr->items[0])) { return null; @@ -234,8 +280,17 @@ private function getCallableDependencies(MutatingScope $scope, Array_ $expr, arr return null; } - $firstItemResult = $itemResults[spl_object_id($expr->items[0]->value)] ?? null; - if ($firstItemResult === null || !$firstItemResult->getType()->isClassString()->yes()) { + $firstItemValue = $expr->items[0]->value; + $firstItemType = null; + if ($firstItemValue instanceof Scalar\String_) { + $firstItemType = new ConstantStringType($firstItemValue->value); + } elseif ($firstItemValue instanceof TypeExpr) { + $firstItemType = $firstItemValue->getExprType(); + } elseif (isset($itemTypes[spl_object_id($firstItemValue)])) { + $firstItemType = $itemTypes[spl_object_id($firstItemValue)][0]; + } + + if ($firstItemType === null || !$firstItemType->isClassString()->yes()) { return null; } @@ -256,25 +311,51 @@ private function getCallableDependencies(MutatingScope $scope, Array_ $expr, arr * A literal generalized by an unpacked item absorbs the closures of its items * into a wider value type - see ClosureSignatureInference::collectAbsorbed(). * - * @param array $itemResults + * @param array $itemTypes */ - private function collectAbsorbedItems(Array_ $expr, array $itemResults, MutatingScope $scope): MutatingScope + private function collectAbsorbedItems(Array_ $expr, array $itemTypes, MutatingScope $scope): MutatingScope { - $itemTypes = []; + $absorbedItemTypes = []; foreach ($expr->items as $arrayItem) { - $itemType = $itemResults[spl_object_id($arrayItem->value)]->getType(); + if ($arrayItem->value instanceof Scalar\String_ || $arrayItem->value instanceof Scalar\Int_ || $arrayItem->value instanceof Scalar\Float_) { + continue; + } + $id = spl_object_id($arrayItem->value); + if (!isset($itemTypes[$id])) { + continue; + } + $itemType = $itemTypes[$id][0]; if (!ClosureSignatureInference::hasMarkers($itemType)) { continue; } - $itemTypes[] = $itemType; + $absorbedItemTypes[] = $itemType; } - if ($itemTypes === []) { + if ($absorbedItemTypes === []) { return $scope; } - $arrayType = $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $inner): Type => $itemResults[spl_object_id($inner)]->getType()); + $arrayType = $this->initializerExprTypeResolver->getArrayType($expr, static function (Expr $inner) use ($itemTypes): Type { + if ($inner instanceof Scalar\String_) { + return new ConstantStringType($inner->value); + } + if ($inner instanceof Scalar\Int_) { + return new ConstantIntegerType($inner->value); + } + if ($inner instanceof Scalar\Float_) { + return new ConstantFloatType($inner->value); + } + if ($inner instanceof TypeExpr) { + return $inner->getExprType(); + } + $id = spl_object_id($inner); + if (array_key_exists($id, $itemTypes)) { + return $itemTypes[$id][0]; + } + + throw new ShouldNotHappenException(); + }); - return $scope->addTemplateArgumentConstraints(ClosureSignatureInference::collectAbsorbedInto($itemTypes, $arrayType)); + return $scope->addTemplateArgumentConstraints(ClosureSignatureInference::collectAbsorbedInto($absorbedItemTypes, $arrayType)); } private function getExpectedArrayType(?Type $type): ?Type @@ -299,4 +380,15 @@ private function getExpectedValueType(?Type $arrayType, Type $keyType): ?Type return $arrayType->getOffsetValueType($keyType); } + private static function hasArrayReference(Expr\Array_ $array): bool + { + foreach ($array->items as $item) { + if ($item->byRef || ($item->value instanceof Expr\Array_ && self::hasArrayReference($item->value))) { + return true; + } + } + + return false; + } + } diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 0a1f5f83e84..eeaab7286f3 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -1372,7 +1372,7 @@ public function applyWrite( $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } - if ($assignedExpr instanceof Expr\Array_) { + if ($assignedExpr instanceof Expr\Array_ && self::hasArrayReference($assignedExpr)) { $scope = $this->processArrayByRefItems($nodeScopeResolver, $scope, $storage, $var->name, $assignedExpr, new Variable($var->name)); } } else { diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index 3a737f65822..e8beacdccca 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -2,11 +2,15 @@ namespace PHPStan\Analyser\ExprHandler; +use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Scalar; use PhpParser\Node\Scalar\InterpolatedString; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; +use PHPStan\Type\Constant\ConstantFloatType; +use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; @@ -41,9 +45,16 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // a literal's type and its initializer context (file/namespace/class) are - // lexical - identical on every scope - so build the context once here. - $initializerExprContext = InitializerExprContext::fromScope($scope); + if ($expr instanceof Node\Scalar\String_) { + $type = new ConstantStringType($expr->value); + } elseif ($expr instanceof Node\Scalar\Int_) { + $type = new ConstantIntegerType($expr->value); + } elseif ($expr instanceof Node\Scalar\Float_) { + $type = new ConstantFloatType($expr->value); + } else { + $initializerExprContext = InitializerExprContext::fromScope($scope); + $type = $this->initializerExprTypeResolver->getType($expr, $initializerExprContext); + } return $this->expressionResultFactory->create( $scope, @@ -53,8 +64,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - typeCallback: fn () => $this->initializerExprTypeResolver->getType($expr, $initializerExprContext), + typeCallback: null, specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + type: $type, + nativeType: $type, ); } diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index a3de0d9a208..f7559382960 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -58,6 +58,11 @@ public function findExpressionResult(Expr $expr): ?ExpressionResult return $this->exprResults[$expr] ?? ($this->fallback !== null ? $this->fallback->findExpressionResult($expr) : null); } + public function removeExpressionResult(Expr $expr): void + { + unset($this->exprResults[$expr]); + } + /** * @param array{TemplateArgumentFrame|null, bool, bool, MutatingScope, MutatingScope, list} $walk */ diff --git a/src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php b/src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php index 4f91110f1a9..51c88c27e2a 100644 --- a/src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php +++ b/src/Rules/Arrays/DuplicateKeysInLiteralArraysRule.php @@ -63,6 +63,9 @@ public function processNode(Node $node, Scope $scope): array $autoGeneratedIndex = null; $seenKeys = []; $seenUnions = []; + $firstOccurrences = []; + /** @var array $seenKeysMap */ + $seenKeysMap = []; foreach ($node->getItemNodes() as $itemNode) { $item = $itemNode->getArrayItem(); if ($item === null) { @@ -153,7 +156,7 @@ public function processNode(Node $node, Scope $scope): array $duplicate = false; $newValues = $keyValues; foreach ($newValues as $k => $newValue) { - if (array_search($newValue, $seenKeys, true) !== false) { + if (((is_int($newValue) || is_string($newValue)) && isset($seenKeysMap[$newValue])) || array_search($newValue, $seenKeys, true) !== false) { unset($newValues[$k]); } @@ -179,10 +182,17 @@ public function processNode(Node $node, Scope $scope): array continue; } - $seenKeys[] = $seenUnions[$k][array_key_first($seenUnions[$k])]; + $promotedKey = $seenUnions[$k][array_key_first($seenUnions[$k])]; + $seenKeys[] = $promotedKey; + if (is_int($promotedKey) || is_string($promotedKey)) { + $seenKeysMap[$promotedKey] = true; + } unset($seenUnions[$k]); } $seenKeys[] = $newValue; + if (is_int($newValue) || is_string($newValue)) { + $seenKeysMap[$newValue] = true; + } } else { $seenUnions[] = $newValues; } @@ -196,20 +206,28 @@ public function processNode(Node $node, Scope $scope): array $value = (string) $value; } - $printedValue = $key !== null - ? $this->exprPrinter->printExpr($key) - : (is_string($value) ? var_export($value, true) : $value); - $printedValues[$value][] = $printedValue; - - if (!isset($valueLines[$value])) { - $valueLines[$value] = $item->getStartLine(); + if (!isset($firstOccurrences[$value])) { + $firstOccurrences[$value] = [$key, $item->getStartLine()]; + continue; } if (!$duplicate) { continue; } - $duplicateKeys[$value] = true; + if (!isset($duplicateKeys[$value])) { + $duplicateKeys[$value] = true; + $firstKey = $firstOccurrences[$value][0]; + $printedValues[$value][] = $firstKey !== null + ? $this->exprPrinter->printExpr($firstKey) + : (is_string($value) ? var_export($value, true) : $value); + $valueLines[$value] = $firstOccurrences[$value][1]; + } + + $printedValue = $key !== null + ? $this->exprPrinter->printExpr($key) + : (is_string($value) ? var_export($value, true) : $value); + $printedValues[$value][] = $printedValue; } } } From c46942164ae21a6a3ba1509884de4f564fa94e02 Mon Sep 17 00:00:00 2001 From: Alfred Bez <1001186+alfredbez@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:55:26 +0000 Subject: [PATCH 2/6] Port the literal-array memory reduction to the Turbo extension ScalarHandler builds the constant types of the literal scalars eagerly and hands them over precomputed, without a typeCallback closure, like its PHP twin. ArrayHandler stores only each non-scalar item's [type, nativeType] pair, collects the items' dependencies while walking them, and evicts the items' expression results once their callback ran, unless a by-reference item keeps the literal alive; ExpressionResultStorage gains the native removeExpressionResult(). The item-type resolution of the type callback, the absorbed-items walk and the callable-array dependencies re-derive the scalar and TypeExpr types from their nodes, as the twin does. On the PHP side, fix the import order and use the early exit the coding standard asks for. --- src/Analyser/ExprHandler/ArrayHandler.php | 14 +- src/Analyser/ExprHandler/ScalarHandler.php | 6 +- turbo-ext/src/ArrayHandler.cpp | 393 ++++++++++++++---- turbo-ext/src/AssignHandler.cpp | 2 +- turbo-ext/src/ExpressionResultStorage.cpp | 30 ++ turbo-ext/src/ScalarHandler.cpp | 94 ++++- turbo-ext/src/generated/ArrayHandler.h | 53 +-- .../src/generated/ExpressionResultStorage.h | 24 +- turbo-ext/src/support.h | 1 + turbo-ext/src/zv.h | 8 + turbo-ext/tests/smoke.php | 4 +- 11 files changed, 478 insertions(+), 151 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index 594f55568a7..bda5a1b6ecd 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -5,11 +5,11 @@ use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; -use PhpParser\Node\Scalar; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name\FullyQualified; +use PhpParser\Node\Scalar; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -183,12 +183,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // storage instead of re-walking the yet-unstored sub-expressions $nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $itemCallbackScope, $storage); - if (!$hasArrayRef) { - if ($arrayItem->key !== null) { - $storage->removeExpressionResult($arrayItem->key); - } - $storage->removeExpressionResult($arrayItem->value); + if ($hasArrayRef) { + continue; + } + + if ($arrayItem->key !== null) { + $storage->removeExpressionResult($arrayItem->key); } + $storage->removeExpressionResult($arrayItem->value); } $nodeScopeResolver->callNodeCallback($nodeCallback, new LiteralArrayNode($expr, $itemNodes), $scope, $storage); if ($nodeScopeResolver->observingTemplateArgumentFrame($scope) !== null) { diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index e8beacdccca..f012dac73b6 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -8,9 +8,6 @@ use PhpParser\Node\Scalar\InterpolatedString; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; -use PHPStan\Type\Constant\ConstantFloatType; -use PHPStan\Type\Constant\ConstantIntegerType; -use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; @@ -22,6 +19,9 @@ use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Turbo\ShadowedByTurboExtension; +use PHPStan\Type\Constant\ConstantFloatType; +use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; /** * @implements ExprHandler diff --git a/turbo-ext/src/ArrayHandler.cpp b/turbo-ext/src/ArrayHandler.cpp index 7211f9d1d85..bb056e59ecd 100644 --- a/turbo-ext/src/ArrayHandler.cpp +++ b/turbo-ext/src/ArrayHandler.cpp @@ -4,13 +4,18 @@ * * A DI service (#[AutowiredService]): the constructor keeps the twin's * arginfo so Nette autowires it. processExpr() is registered as the class's - * handler entry (Engine.h). The twin's closures are native closures capturing - * what the PHP closures capture: the typeCallback ($this, $expr, - * $itemResults, $beforeScope); the item-type callback it hands to - * InitializerExprTypeResolver::getArrayType() ($itemResults, - * $nativeTypesPromoted) is a pt_ietr_get_type over the typeCallback's frame - * (the resolver calls it synchronously); the specifyTypesCallback is - * SpecifiedTypes::emptySpecifyCallback(), as in the twin. + * handler entry (Engine.h). The twin keeps only each non-scalar item's + * [type, nativeType] pair ($itemTypes) — the scalar keys/values re-derive + * their constant type from the node — and evicts each item's results from + * the storage once its callback ran (unless the literal has a by-reference + * item), so a large literal array does not pin every ExpressionResult. The + * twin's closures are native closures capturing what the PHP closures + * capture: the typeCallback ($this, $expr, $itemTypes, $beforeScope); the + * item-type callback it hands to InitializerExprTypeResolver::getArrayType() + * ($itemTypes, $nativeTypesPromoted) is a pt_ietr_get_type over the + * typeCallback's frame (the resolver calls it synchronously); the + * specifyTypesCallback is SpecifiedTypes::emptySpecifyCallback(), as in the + * twin. * * NodeScopeResolver, MutatingScope, ExpressionResult, ExpressionContext, * VariableFlow(Builder), VariableWriteOffset, TypeCombinator and the Type @@ -32,6 +37,7 @@ namespace slots = ptdecl::ArrayHandler::slot; namespace sigs = ptdecl::ArrayHandler::sig; #include "CallHandlerSupport.h" +#include "ParserVisitors.h" zend_class_entry *pt_ce_array_handler = nullptr; @@ -71,6 +77,132 @@ zval *itemByRef(zval *item) { return nodeProperty(pt_arh_item_by_ref_site, item, /* }}} */ +/* {{{ the nodes the twin resolves without a stored result: the plain + * scalars' value properties and TypeExpr::getExprType() */ + +using phpstanturbo::visitors::NodeProp; + +NodeProp pt_arh_scalar_string_value = PT_NODE_PROP(PT_CLASS_SCALAR_STRING, "value"); +NodeProp pt_arh_scalar_int_value = PT_NODE_PROP(PT_CLASS_SCALAR_INT, "value"); +NodeProp pt_arh_scalar_float_value = PT_NODE_PROP(PT_CLASS_SCALAR_FLOAT, "value"); +NodeProp pt_arh_type_expr_type = PT_NODE_PROP(PT_CLASS_TYPE_EXPR, "exprType"); + +/* $node-> (dereferenced); NULL = pending exception */ +zval *nodePropValue(NodeProp &prop, zval *node) +{ + zval *value = prop.of(Z_OBJ_P(node)); + if (EXPECTED(value != NULL && Z_TYPE_P(value) != IS_UNDEF)) return value; + if (EG(exception) != NULL) return NULL; + if (value == NULL) { + zend_throw_error(NULL, "Undefined property: %s::$%s", ZSTR_VAL(Z_OBJCE_P(node)->name), prop.name); + return NULL; + } + zend_throw_error(NULL, "Typed property %s::$%s must not be accessed before initialization", ZSTR_VAL(Z_OBJCE_P(node)->name), prop.name); + return NULL; +} + +/* $inner instanceof Scalar\String_ ? new ConstantStringType($inner->value) + * : (Int_ / Float_ alike): 1 = produced, 0 = not a plain scalar node, + * -1 = pending exception */ +int scalarConstantType(zval *node, zv::Val &out) +{ + if (Z_TYPE_P(node) != IS_OBJECT) return 0; + zend_class_entry *ce = Z_OBJCE_P(node); + zend_class_entry *stringCe = pt_class(PT_CLASS_SCALAR_STRING); + zend_class_entry *intCe = pt_class(PT_CLASS_SCALAR_INT); + zend_class_entry *floatCe = pt_class(PT_CLASS_SCALAR_FLOAT); + if (UNEXPECTED(stringCe == NULL || intCe == NULL || floatCe == NULL)) return -1; + if (instanceof_function(ce, stringCe)) { + zval *value = nodePropValue(pt_arh_scalar_string_value, node); + if (UNEXPECTED(value == NULL)) return -1; + if (EXPECTED(Z_TYPE_P(value) == IS_STRING)) { + zval z; + if (UNEXPECTED(!pt_constant_string_type_new(&z, Z_STR_P(value), false))) return -1; + out = zv::Val::adopt(z); + return 1; + } + out = pt_type_new_ce(pt_ce_constant_string_type, 1, value); + return out.isUndef() ? -1 : 1; + } + if (instanceof_function(ce, intCe)) { + zval *value = nodePropValue(pt_arh_scalar_int_value, node); + if (UNEXPECTED(value == NULL)) return -1; + if (EXPECTED(Z_TYPE_P(value) == IS_LONG)) { + zval z; + if (UNEXPECTED(!pt_constant_integer_type_new(&z, Z_LVAL_P(value)))) return -1; + out = zv::Val::adopt(z); + return 1; + } + out = pt_type_new_ce(pt_ce_constant_integer_type, 1, value); + return out.isUndef() ? -1 : 1; + } + if (instanceof_function(ce, floatCe)) { + zval *value = nodePropValue(pt_arh_scalar_float_value, node); + if (UNEXPECTED(value == NULL)) return -1; + if (EXPECTED(Z_TYPE_P(value) == IS_DOUBLE)) { + zval z; + if (UNEXPECTED(!pt_constant_float_type_new(&z, Z_DVAL_P(value)))) return -1; + out = zv::Val::adopt(z); + return 1; + } + out = pt_type_new_ce(pt_ce_constant_float_type, 1, value); + return out.isUndef() ? -1 : 1; + } + return 0; +} + +/* whether $node is a Scalar\String_ / Scalar\Int_ / Scalar\Float_ (the twin's + * store skip); false = pending exception */ +[[nodiscard]] bool isPlainScalar(zval *node, bool &out) +{ + if (Z_TYPE_P(node) != IS_OBJECT) { + out = false; + return true; + } + zend_class_entry *ce = Z_OBJCE_P(node); + zend_class_entry *stringCe = pt_class(PT_CLASS_SCALAR_STRING); + zend_class_entry *intCe = pt_class(PT_CLASS_SCALAR_INT); + zend_class_entry *floatCe = pt_class(PT_CLASS_SCALAR_FLOAT); + if (UNEXPECTED(stringCe == NULL || intCe == NULL || floatCe == NULL)) return false; + out = instanceof_function(ce, stringCe) || instanceof_function(ce, intCe) || instanceof_function(ce, floatCe); + return true; +} + +/* $inner->getExprType() of a TypeExpr; UNDEF = pending exception */ +zv::Val typeExprType(zval *node) +{ + zval *type = nodePropValue(pt_arh_type_expr_type, node); + if (UNEXPECTED(type == NULL)) return zv::Val(); + if (UNEXPECTED(Z_TYPE_P(type) != IS_OBJECT)) { + zend_type_error("PHPStan\\Node\\Expr\\TypeExpr::getExprType(): Return value must be of type PHPStan\\Type\\Type, %s given", zend_zval_value_name(type)); + return zv::Val(); + } + return zv::Val::copyOf(zv::Ref(type)); +} + +/* }}} */ + +/* {{{ hasArrayReference()'s non-throwing reads (an Array_ node's items are + * always initialized ArrayItem nodes - a missing one simply answers "no + * reference here", like AssignHandler's port of the same twin static) */ + +pt_property_site pt_arh_ref_items_site; +pt_property_site pt_arh_ref_value_site; +pt_property_site pt_arh_ref_by_ref_site; + +/* $object->$name through the site; NULL when absent or uninitialized */ +zval *rawNodeProp(pt_property_site &site, zval *object, const char *name, size_t len) +{ + if (Z_TYPE_P(object) != IS_OBJECT) return NULL; + zval *slot = pt_property_cached(site, Z_OBJ_P(object), name, len); + if (slot == NULL) return NULL; + ZVAL_DEREF(slot); + if (Z_TYPE_P(slot) == IS_UNDEF) return NULL; + return slot; +} + +/* }}} */ + /* {{{ the VariableWrite value-flow target's getters */ /* $write->isOffsetWrite() / ->getVariableName() / ->getId(): the slot of @@ -170,8 +302,9 @@ class ArrayHandler uint32_t itemCount = Z_TYPE_P(items.raw()) == IS_ARRAY ? zend_hash_num_elements(Z_ARRVAL_P(items.raw())) : 0; zv::Arr itemNodes = zv::Arr::create(itemCount); - zv::Arr itemResults = zv::Arr::create(itemCount); + zv::Arr itemTypes = zv::Arr::create(itemCount); zv::Arr variableFlows = zv::Arr::create(itemCount); + zv::Arr dependencies = zv::Arr::empty(); bool hasYield = false; zv::Val throwPoints = zv::Val(zv::Arr::empty()); zv::Val impurePoints = zv::Val(zv::Arr::empty()); @@ -210,6 +343,7 @@ class ArrayHandler if (UNEXPECTED(nativePassedToType.isUndef())) return zv::Val(); } bool hasExpectedType = !passedToType.isNull() || !nativePassedToType.isNull(); + bool hasArrayRef = hasArrayReference(expr); bool hasLiteralWrite = !literalWrite.isNull(); zval nextIndex; ZVAL_LONG(&nextIndex, 0); @@ -249,11 +383,7 @@ class ArrayHandler zv::Val heldKey = zv::Val::copyOf(zv::Ref(key)); keyResult = pt_node_scope_resolver_process_expr_node(nodeScopeResolver, stmt, heldKey.raw(), scope.raw(), storage, nodeCallback, keyContext.raw()); if (UNEXPECTED(keyResult.isUndef())) return zv::Val(); - if (UNEXPECTED(Z_TYPE_P(heldKey.raw()) != IS_OBJECT)) { - zend_type_error("spl_object_id(): Argument #1 ($object) must be of type object, %s given", zend_zval_value_name(heldKey.raw())); - return zv::Val(); - } - if (UNEXPECTED(!storeItemResult(itemResults, NULL, heldKey.raw(), keyResult.raw(), variableFlows, hasYield, throwPoints, impurePoints, isAlwaysTerminating, scope, hold))) return zv::Val(); + if (UNEXPECTED(!collectItemResult(itemTypes, dependencies, NULL, heldKey.raw(), keyResult.raw(), variableFlows, hasYield, throwPoints, impurePoints, isAlwaysTerminating, scope, hold))) return zv::Val(); } zval *unpack = itemUnpack(arrayItem); @@ -338,15 +468,22 @@ class ArrayHandler zv::Val heldValue = zv::Val::copyOf(zv::Ref(value)); zv::Val valueResult = pt_node_scope_resolver_process_expr_node(nodeScopeResolver, stmt, heldValue.raw(), scope.raw(), storage, nodeCallback, valueContext.raw()); if (UNEXPECTED(valueResult.isUndef())) return zv::Val(); - if (UNEXPECTED(Z_TYPE_P(heldValue.raw()) != IS_OBJECT)) { - zend_type_error("spl_object_id(): Argument #1 ($object) must be of type object, %s given", zend_zval_value_name(heldValue.raw())); - return zv::Val(); - } - if (UNEXPECTED(!storeItemResult(itemResults, arrayItem, heldValue.raw(), valueResult.raw(), variableFlows, hasYield, throwPoints, impurePoints, isAlwaysTerminating, scope, hold))) return zv::Val(); + if (UNEXPECTED(!collectItemResult(itemTypes, dependencies, arrayItem, heldValue.raw(), valueResult.raw(), variableFlows, hasYield, throwPoints, impurePoints, isAlwaysTerminating, scope, hold))) return zv::Val(); // the item's callback fires after its key and value were processed, // with the item's entry scope if (UNEXPECTED(!pt_node_scope_resolver_call_node_callback(nodeScopeResolver, nodeCallback, arrayItem, itemCallbackScope.raw(), storage))) return zv::Val(); + + // an item's results are dead once their callback ran - unless a + // by-reference item keeps the literal's inner state alive + if (!hasArrayRef) { + zval *evictKey = itemKey(arrayItem); + if (UNEXPECTED(evictKey == NULL)) return zv::Val(); + if (Z_TYPE_P(evictKey) != IS_NULL && UNEXPECTED(!pt_expression_result_storage_remove(storage, evictKey))) return zv::Val(); + zval *evictValue = itemValue(arrayItem); + if (UNEXPECTED(evictValue == NULL)) return zv::Val(); + if (UNEXPECTED(!pt_expression_result_storage_remove(storage, evictValue))) return zv::Val(); + } } } { @@ -360,15 +497,15 @@ class ArrayHandler zv::Val observingFrame = pt_node_scope_resolver_observing_template_argument_frame(nodeScopeResolver, scope.raw()); if (UNEXPECTED(observingFrame.isUndef())) return zv::Val(); if (!observingFrame.isNull()) { - scope = collectAbsorbedItems(expr, itemResults.raw(), scope.raw()); + scope = collectAbsorbedItems(expr, itemTypes.raw(), scope.raw()); if (UNEXPECTED(scope.isUndef())) return zv::Val(); } } zv::Val variableFlow = pt_variable_flow_sequence_list(variableFlows.table()); if (UNEXPECTED(variableFlow.isUndef())) return zv::Val(); - zv::Val itemResultsValue(std::move(itemResults)); - zv::Val typeCallback = pt_native_closure(&typeCallbackBody, self, expr, itemResultsValue.raw(), beforeScope); + zv::Val itemTypesValue(std::move(itemTypes)); + zv::Val typeCallback = pt_native_closure(&typeCallbackBody, self, expr, itemTypesValue.raw(), beforeScope); zv::Val specifyTypesCallback = pt_specified_types_empty_specify_callback(); if (UNEXPECTED(specifyTypesCallback.isUndef())) return zv::Val(); @@ -377,66 +514,64 @@ class ArrayHandler zv::Val result = pt_expression_result_create(OBJ_PROP_NUM(self, slots::expressionResultFactory), args); if (UNEXPECTED(result.isUndef())) return zv::Val(); - // Dependencies::merge($callableDependencies, ...$itemResults' dependencies): - // the non-null ones gathered first, so that an array whose items depend on - // nothing allocates nothing - zv::Val callableDependencies = getCallableDependencies(beforeScope, expr, itemResultsValue.raw(), result.raw()); + // Dependencies::merge(...$dependencies): the items' own dependencies in + // processing order (gathered in the loop), the callable's last; an array + // whose items depend on nothing merges to null - withDependencies(null) + // is the same object then + zv::Val callableDependencies = getCallableDependencies(beforeScope, expr, itemTypesValue.raw(), result.raw()); if (UNEXPECTED(callableDependencies.isUndef())) return zv::Val(); - zv::Arr dependencyList; - if (!callableDependencies.isNull()) { - dependencyList = zv::Arr::create(1); - dependencyList.push(std::move(callableDependencies)); - } - for (auto entry : zv::ArrRef(itemResultsValue.raw())) { - zv::Val dependenciesHold; - zval *itemDependencies = pt_expression_result_dependencies(entry.value().deref().raw(), dependenciesHold); - if (UNEXPECTED(itemDependencies == NULL)) return zv::Val(); - if (Z_TYPE_P(itemDependencies) == IS_NULL) continue; - if (dependencyList.isUndef()) dependencyList = zv::Arr::create(1); - dependencyList.push(zv::Ref(itemDependencies)); - } - if (dependencyList.isUndef()) return result; - zv::Val dependencies = pt_dependencies_merge_list(dependencyList.table()); - if (UNEXPECTED(dependencies.isUndef())) return zv::Val(); - return pt_expression_result_with_dependencies(result.raw(), dependencies.raw()); + if (!callableDependencies.isNull()) dependencies.push(std::move(callableDependencies)); + if (zend_hash_num_elements(dependencies.table()) == 0) return result; + zv::Val mergedDependencies = pt_dependencies_merge_list(dependencies.table()); + if (UNEXPECTED(mergedDependencies.isUndef())) return zv::Val(); + return pt_expression_result_with_dependencies(result.raw(), mergedDependencies.raw()); } /* Mirrors collectAbsorbedItems() */ - zv::Val collectAbsorbedItems(zval *expr, zval *itemResults, zval *scopeArg) const + zv::Val collectAbsorbedItems(zval *expr, zval *itemTypes, zval *scopeArg) const { zv::Val scope = zv::Val::copyOf(zv::Ref(scopeArg)); zval *items = exprItems(expr); if (UNEXPECTED(items == NULL)) return zv::Val(); if (Z_TYPE_P(items) != IS_ARRAY) return scope; zv::Val itemsHold = zv::Val::copyOf(zv::Ref(items)); - zv::Arr itemTypes = zv::Arr::create(0); + zv::Arr absorbedTypes = zv::Arr::create(0); for (zv::ArrayEntry entry : zv::ArrRef(itemsHold.raw())) { zval *arrayItem = entry.value().deref().raw(); zval *value = itemValue(arrayItem); if (UNEXPECTED(value == NULL)) return zv::Val(); - zval *itemResult = zend_hash_index_find(Z_ARRVAL_P(itemResults), Z_OBJ_HANDLE_P(value)); - if (UNEXPECTED(itemResult == NULL)) { - zend_error(E_WARNING, "Undefined array key %u", Z_OBJ_HANDLE_P(value)); + bool plainScalar; + if (UNEXPECTED(!isPlainScalar(value, plainScalar))) return zv::Val(); + if (plainScalar) continue; + if (UNEXPECTED(Z_TYPE_P(value) != IS_OBJECT)) { + zend_type_error("spl_object_id(): Argument #1 ($object) must be of type object, %s given", zend_zval_value_name(value)); + return zv::Val(); + } + zval *pair = zend_hash_index_find(Z_ARRVAL_P(itemTypes), Z_OBJ_HANDLE_P(value)); + if (pair == NULL || Z_TYPE_P(pair) == IS_NULL) continue; + ZVAL_DEREF(pair); + zval *itemType = Z_TYPE_P(pair) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL_P(pair), 0) : NULL; + if (UNEXPECTED(itemType == NULL)) { + zend_type_error("PHPStan\\Analyser\\Generics\\ClosureSignatureInference::hasMarkers(): Argument #1 ($type) must be of type PHPStan\\Type\\Type, null given"); return zv::Val(); } - zv::Val itemType = pt_expression_result_get_type(itemResult); - if (UNEXPECTED(itemType.isUndef())) return zv::Val(); + ZVAL_DEREF(itemType); bool hasMarkers; - if (UNEXPECTED(!pt_closure_signature_inference_has_markers(itemType.raw(), hasMarkers))) return zv::Val(); + if (UNEXPECTED(!pt_closure_signature_inference_has_markers(itemType, hasMarkers))) return zv::Val(); if (!hasMarkers) continue; - itemTypes.push(std::move(itemType)); + absorbedTypes.push(zv::Ref(itemType)); } - if (zend_hash_num_elements(itemTypes.table()) == 0) return scope; + if (zend_hash_num_elements(absorbedTypes.table()) == 0) return scope; zv::Val arrayType; { - ItemTypeFrame frame{itemResults, false}; + ItemTypeFrame frame{itemTypes, false}; pt_ietr_get_type getTypeCallback{&itemTypeCallback, &frame, &itemTypeCallable}; arrayType = getArrayType(OBJ_PROP_NUM(self, slots::initializerExprTypeResolver), expr, getTypeCallback); if (UNEXPECTED(arrayType.isUndef())) return zv::Val(); } - zv::Val itemTypesHold(std::move(itemTypes)); - zv::Val absorbed = pt_closure_signature_inference_collect_absorbed_into(itemTypesHold.raw(), arrayType.raw()); + zv::Val absorbedTypesHold(std::move(absorbedTypes)); + zv::Val absorbed = pt_closure_signature_inference_collect_absorbed_into(absorbedTypesHold.raw(), arrayType.raw()); if (UNEXPECTED(absorbed.isUndef())) return zv::Val(); return pt_mutating_scope_add_template_argument_constraints(Z_OBJ_P(scope.raw()), absorbed.raw()); } @@ -453,7 +588,7 @@ class ArrayHandler /* Mirrors getCallableDependencies(): an array that may be a callable - * `[Foo::class, 'method']` - depends on what calling it returns; PHP null * for nothing, UNDEF = pending exception */ - static zv::Val getCallableDependencies(zval *scope, zval *expr, zval *itemResults, zval *result) + static zv::Val getCallableDependencies(zval *scope, zval *expr, zval *itemTypes, zval *result) { zval *items = exprItems(expr); if (UNEXPECTED(items == NULL)) return zv::Val(); @@ -491,14 +626,45 @@ class ArrayHandler } zval *value = itemValue(first); if (UNEXPECTED(value == NULL)) return zv::Val(); - if (UNEXPECTED(Z_TYPE_P(value) != IS_OBJECT)) { - zend_type_error("spl_object_id(): Argument #1 ($object) must be of type object, %s given", zend_zval_value_name(value)); - return zv::Val(); + // $firstItemType: derived from the node of a plain string or a + // TypeExpr, the stored [type, nativeType] pair otherwise + zv::Val firstItemType = zv::Val::null(); + int isString = isInstanceOf(value, PT_CLASS_SCALAR_STRING); + if (UNEXPECTED(isString < 0)) return zv::Val(); + if (isString == 1) { + int produced = scalarConstantType(value, firstItemType); + if (UNEXPECTED(produced < 0)) return zv::Val(); + if (UNEXPECTED(produced != 1)) { + pt_throw_should_not_happen(); + return zv::Val(); + } + } else { + int isTypeExpr = isInstanceOf(value, PT_CLASS_TYPE_EXPR); + if (UNEXPECTED(isTypeExpr < 0)) return zv::Val(); + if (isTypeExpr == 1) { + firstItemType = typeExprType(value); + if (UNEXPECTED(firstItemType.isUndef())) return zv::Val(); + } else { + if (UNEXPECTED(Z_TYPE_P(value) != IS_OBJECT)) { + zend_type_error("spl_object_id(): Argument #1 ($object) must be of type object, %s given", zend_zval_value_name(value)); + return zv::Val(); + } + zval *pair = zend_hash_index_find(Z_ARRVAL_P(itemTypes), Z_OBJ_HANDLE_P(value)); + if (pair != NULL && Z_TYPE_P(pair) != IS_NULL) { + ZVAL_DEREF(pair); + zval *stored = Z_TYPE_P(pair) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL_P(pair), 0) : NULL; + if (UNEXPECTED(stored == NULL)) { + // the pair is [type, nativeType] by construction + pt_throw_should_not_happen(); + return zv::Val(); + } + ZVAL_DEREF(stored); + firstItemType = zv::Val::copyOf(zv::Ref(stored)); + } + } } - zval *firstItemResult = zend_hash_index_find(Z_ARRVAL_P(itemResults), Z_OBJ_HANDLE_P(value)); - if (firstItemResult == NULL || Z_TYPE_P(firstItemResult) == IS_NULL) return zv::Val::null(); - zv::Val firstItemType = pt_expression_result_get_type(firstItemResult); - if (UNEXPECTED(firstItemType.isUndef())) return zv::Val(); + + if (firstItemType.isNull()) return zv::Val::null(); zend_long isClassString = pt_type_call_trinary(Z_OBJ_P(firstItemType.raw()), PT_LC("isclassstring"), 0, NULL); if (UNEXPECTED(isClassString < 0)) return zv::Val(); if (isClassString != PT_TRI_YES) return zv::Val::null(); @@ -522,19 +688,39 @@ class ArrayHandler return pt_dependencies_create_in(scope, returnTypes.raw()); } - /* $itemResults[spl_object_id($node)] = $result; $variableFlows[] = - * $result->getVariableFlow(); (for a value: the escape of a by-reference - * item); the $hasYield / $throwPoints / $impurePoints / - * $isAlwaysTerminating folds; $scope = $result->getScope(); false = - * pending exception */ - [[nodiscard]] static bool storeItemResult(zv::Arr &itemResults, zval *arrayItem, zval *node, zval *result, zv::Arr &variableFlows, bool &hasYield, zv::Val &throwPoints, zv::Val &impurePoints, bool &isAlwaysTerminating, zv::Val &scope, zv::Val &hold) + /* the per-item bookkeeping of the twin's loop: a non-scalar node's + * [getType(), getNativeType()] pair goes to $itemTypes (the plain scalars' + * constant types are re-derived from the node wherever they are needed); + * the result's own dependencies and variable flow follow it (a + * by-reference value's escape too); the $hasYield / $throwPoints / + * $impurePoints / $isAlwaysTerminating folds; $scope = + * $result->getScope(); false = pending exception */ + [[nodiscard]] static bool collectItemResult(zv::Arr &itemTypes, zv::Arr &dependencies, zval *arrayItem, zval *node, zval *result, zv::Arr &variableFlows, bool &hasYield, zv::Val &throwPoints, zv::Val &impurePoints, bool &isAlwaysTerminating, zv::Val &scope, zv::Val &hold) { - zval stored; - ZVAL_COPY(&stored, result); - zend_hash_index_update(itemResults.table(), Z_OBJ_HANDLE_P(node), &stored); + bool plainScalar; + if (UNEXPECTED(!isPlainScalar(node, plainScalar))) return false; + if (!plainScalar) { + if (UNEXPECTED(Z_TYPE_P(node) != IS_OBJECT)) { + zend_type_error("spl_object_id(): Argument #1 ($object) must be of type object, %s given", zend_zval_value_name(node)); + return false; + } + zv::Val type = pt_expression_result_get_type(result); + if (UNEXPECTED(type.isUndef())) return false; + zv::Val nativeType = pt_expression_result_get_native_type(result); + if (UNEXPECTED(nativeType.isUndef())) return false; + zv::Arr pair = zv::Arr::create(2); + pair.push(std::move(type)); + pair.push(std::move(nativeType)); + zval stored; + ZVAL_COPY(&stored, pair.raw()); + zend_hash_index_update(itemTypes.table(), Z_OBJ_HANDLE_P(node), &stored); + } + zval *itemDependencies = pt_expression_result_dependencies(result, hold); + if (UNEXPECTED(itemDependencies == NULL)) return false; + if (Z_TYPE_P(itemDependencies) != IS_NULL) dependencies.push(zv::Ref(itemDependencies)); zv::Val flow = pt_expression_result_variable_flow(result); if (UNEXPECTED(flow.isUndef())) return false; - variableFlows.push(std::move(flow)); + if (!flow.isNull()) variableFlows.push(std::move(flow)); if (arrayItem != NULL) { // only the value side: `if ($arrayItem->byRef)` zval *byRef = itemByRef(arrayItem); @@ -597,12 +783,33 @@ class ArrayHandler return pt_type_op(Z_OBJ_P(arrayType), PT_OP_GET_OFFSET_VALUE_TYPE, 1, keyType); } - /* static function (Expr $inner) use ($itemResults, $nativeTypesPromoted): + /* Mirrors hasArrayReference() */ + static bool hasArrayReference(zval *array) + { + zval *items = rawNodeProp(pt_arh_ref_items_site, array, PT_LC("items")); + if (items == NULL || Z_TYPE_P(items) != IS_ARRAY) return false; + for (zv::ArrayEntry entry : zv::TableRef(Z_ARRVAL_P(items))) { + zval *item = entry.value().deref().raw(); + zval *byRef = rawNodeProp(pt_arh_ref_by_ref_site, item, PT_LC("byRef")); + if (byRef != NULL && zend_is_true(byRef)) return true; + zval *value = rawNodeProp(pt_arh_ref_value_site, item, PT_LC("value")); + if (value == NULL) continue; + int isArray = isInstanceOf(value, PT_CLASS_ARRAY_EXPR); + if (isArray == 1) { + bool nested = false; + pt_engine_with_stack([&]() { nested = hasArrayReference(value); }); + if (nested) return true; + } + } + return false; + } + + /* static function (Expr $inner) use ($itemTypes, $nativeTypesPromoted): * Type — InitializerExprTypeResolver calls it synchronously, over the * typeCallback's frame */ struct ItemTypeFrame { - zval *itemResults; + zval *itemTypes; bool nativeTypesPromoted; }; @@ -616,24 +823,40 @@ class ArrayHandler zend_type_error("%s(): Argument #1 ($inner) must be of type PhpParser\\Node\\Expr, %s given", pt_arh_closure_name, zend_zval_value_name(inner)); return zv::Val(); } - zval *itemResult = zend_hash_index_find(Z_ARRVAL_P(frame->itemResults), Z_OBJ_HANDLE_P(inner)); - if (UNEXPECTED(itemResult == NULL)) { + // a plain scalar's constant type comes from the node, a TypeExpr's + // from itself; only the rest was stored in $itemTypes + zv::Val scalar; + int isScalar = scalarConstantType(inner, scalar); + if (UNEXPECTED(isScalar < 0)) return zv::Val(); + if (isScalar == 1) return scalar; + int isTypeExpr = isInstanceOf(inner, PT_CLASS_TYPE_EXPR); + if (UNEXPECTED(isTypeExpr < 0)) return zv::Val(); + if (isTypeExpr == 1) return typeExprType(inner); + zval *pair = zend_hash_index_find(Z_ARRVAL_P(frame->itemTypes), Z_OBJ_HANDLE_P(inner)); + if (pair == NULL) { + pt_throw_should_not_happen(); + return zv::Val(); + } + ZVAL_DEREF(pair); + zval *stored = Z_TYPE_P(pair) == IS_ARRAY ? zend_hash_index_find(Z_ARRVAL_P(pair), frame->nativeTypesPromoted ? 1 : 0) : NULL; + if (UNEXPECTED(stored == NULL)) { pt_throw_should_not_happen(); return zv::Val(); } - return frame->nativeTypesPromoted ? pt_expression_result_get_native_type(itemResult) : pt_expression_result_get_type(itemResult); + ZVAL_DEREF(stored); + return zv::Val::copyOf(zv::Ref(stored)); } /* the callback as a PHP callable that outlives the call (the resolver * hands it to OversizedArrayBuilder): the closure over copies of - * $itemResults and $nativeTypesPromoted */ + * $itemTypes and $nativeTypesPromoted */ static zv::Val itemTypeCallable(void *data) { ItemTypeFrame *frame = static_cast(data); - return pt_native_closure(&itemTypeCallbackBody, frame->itemResults, frame->nativeTypesPromoted); + return pt_native_closure(&itemTypeCallbackBody, frame->itemTypes, frame->nativeTypesPromoted); } - /* the same closure called from PHP — captures: $itemResults, + /* the same closure called from PHP — captures: $itemTypes, * $nativeTypesPromoted */ static void itemTypeCallbackBody(zval *captures, uint32_t argc, zval *argv, zval *return_value) { @@ -645,11 +868,11 @@ class ArrayHandler } /* the typeCallback's body */ - zv::Val resolveType(bool nativeTypesPromoted, zval *expr, zval *itemResults, zval *beforeScope) const + zv::Val resolveType(bool nativeTypesPromoted, zval *expr, zval *itemTypes, zval *beforeScope) const { zv::Val type; { - ItemTypeFrame frame{itemResults, nativeTypesPromoted}; + ItemTypeFrame frame{itemTypes, nativeTypesPromoted}; pt_ietr_get_type getTypeCallback{&itemTypeCallback, &frame, &itemTypeCallable}; type = getArrayType(OBJ_PROP_NUM(self, slots::initializerExprTypeResolver), expr, getTypeCallback); if (UNEXPECTED(type.isUndef())) return zv::Val(); @@ -710,8 +933,8 @@ class ArrayHandler return pt_type_combinator_intersect(2, intersectArgv); } - /* function (bool $nativeTypesPromoted) use ($expr, $itemResults, - * $beforeScope): Type — captures: $this, $expr, $itemResults, $beforeScope */ + /* function (bool $nativeTypesPromoted) use ($expr, $itemTypes, + * $beforeScope): Type — captures: $this, $expr, $itemTypes, $beforeScope */ static void typeCallbackBody(zval *captures, uint32_t argc, zval *argv, zval *return_value) { if (UNEXPECTED(!requireArguments(argc, 1, pt_arh_closure_name))) return; diff --git a/turbo-ext/src/AssignHandler.cpp b/turbo-ext/src/AssignHandler.cpp index f5a81e3b4bc..320afef54f3 100644 --- a/turbo-ext/src/AssignHandler.cpp +++ b/turbo-ext/src/AssignHandler.cpp @@ -2112,7 +2112,7 @@ class AssignHandler } } - if (ahIs(assignedExpr.raw(), PT_CLASS_ARRAY_EXPR)) { + if (ahIs(assignedExpr.raw(), PT_CLASS_ARRAY_EXPR) && hasArrayReference(assignedExpr.raw())) { AH_VALB(rootVariable, newVariable(varName)); AH_SETB(scope, processArrayByRefItems(nsr, scope.raw(), storage, varName, assignedExpr.raw(), rootVariable.raw())); } diff --git a/turbo-ext/src/ExpressionResultStorage.cpp b/turbo-ext/src/ExpressionResultStorage.cpp index 69a85909eb8..88e1c4e0b11 100644 --- a/turbo-ext/src/ExpressionResultStorage.cpp +++ b/turbo-ext/src/ExpressionResultStorage.cpp @@ -83,6 +83,17 @@ class ExpressionResultStorage } } + /* Mirrors removeExpressionResult(): unset($this->exprResults[$expr]) - + * this storage's own entries only, the read-only fallback chain keeps + * its copies (writes never reach it) */ + void removeExpressionResult(zval *expr) + { + zend_ulong id = Z_OBJ_HANDLE_P(expr); + zv::ObjRef obj(self); + zv::ArrRef(obj.propAt(slots::exprResults).raw()).unsetIndex(id); + zv::ArrRef(obj.propAt(slots::fallback).raw()).unsetIndex(id); + } + /* Mirrors storeByRefInvocationWalk(): the walks keyed by the closure's * handle as [closure, list of walks] - the closure is pinned like the * stored Exprs */ @@ -214,6 +225,16 @@ PT_MINIT_REGISTRATION(pt_register_expression_result_storage) ExpressionResultStorage(ZEND_THIS).findExpressionResult(expr).intoReturnValue(return_value); }); + cls.method(sigs::removeExpressionResult, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *expr; + zend_class_entry *exprCe = pt_class(PT_CLASS_EXPR); + if (UNEXPECTED(exprCe == NULL)) RETURN_THROWS(); + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_OBJECT_OF_CLASS(expr, exprCe) + ZEND_PARSE_PARAMETERS_END(); + ExpressionResultStorage(ZEND_THIS).removeExpressionResult(expr); + }); + cls.method(sigs::storeByRefInvocationWalk, [](INTERNAL_FUNCTION_PARAMETERS) { zval *closure, *walk; zend_class_entry *closureCe = pt_class(PT_CLASS_CLOSURE_EXPR); @@ -254,6 +275,15 @@ void pt_expression_result_storage_store(zval *storage, zval *expr, zval *express (void) pt_type_call(Z_OBJ_P(storage), "storeexpressionresult", sizeof("storeexpressionresult") - 1, 2, argv); } +bool pt_expression_result_storage_remove(zval *storage, zval *expr) +{ + if (EXPECTED(Z_OBJCE_P(storage) == pt_ce_expression_result_storage)) { + ExpressionResultStorage(storage).removeExpressionResult(expr); + return true; + } + return !pt_type_call(Z_OBJ_P(storage), "removeexpressionresult", sizeof("removeexpressionresult") - 1, 1, expr).isUndef(); +} + zv::Val pt_expression_result_storage_new() { return pt_type_new_ce(pt_ce_expression_result_storage, 0, NULL); diff --git a/turbo-ext/src/ScalarHandler.cpp b/turbo-ext/src/ScalarHandler.cpp index 73d47a6fea0..1eb7810a72c 100644 --- a/turbo-ext/src/ScalarHandler.cpp +++ b/turbo-ext/src/ScalarHandler.cpp @@ -5,9 +5,11 @@ * A DI service (#[AutowiredService]): the constructor keeps the twin's * arginfo so Nette autowires it. processExpr() is registered as the * class's handler entry (Engine.h), so native callers dispatch to it - * without an engine frame. The typeCallback is a native closure capturing - * what the twin's arrow function captures ($this, $expr and the initializer - * context); the result is built through pt_expression_result_create(). + * without an engine frame. The literal scalars (String_, Int_, Float_) get + * their constant type directly; anything else (the magic constants) goes + * through InitializerExprTypeResolver::getType() once, eagerly - the result + * is built through pt_expression_result_create() with the precomputed + * type/nativeType and no typeCallback, like the twin. * * SpecifiedTypes and InitializerExprTypeResolver are called through their * direct entries; InitializerExprContext, which stays PHP for now, through the @@ -22,11 +24,14 @@ namespace sigs = ptdecl::ScalarHandler::sig; #include "zv.h" #include "TypeTraits.h" #include "Engine.h" +#include "ParserVisitors.h" zend_class_entry *pt_ce_scalar_handler = nullptr; namespace { +using phpstanturbo::visitors::NodeProp; + /* {{{ the PHP collaborators (one site each; switch to their direct entries * once they are ported) */ @@ -45,6 +50,28 @@ zv::Val initializerExprType(zval *initializerExprTypeResolver, zval *expr, zval /* }}} */ +/* {{{ the scalar nodes' value properties */ + +NodeProp pt_sh_scalar_string_value = PT_NODE_PROP(PT_CLASS_SCALAR_STRING, "value"); +NodeProp pt_sh_scalar_int_value = PT_NODE_PROP(PT_CLASS_SCALAR_INT, "value"); +NodeProp pt_sh_scalar_float_value = PT_NODE_PROP(PT_CLASS_SCALAR_FLOAT, "value"); + +/* $node->value (dereferenced); NULL = pending exception */ +zval *nodeProp(NodeProp &prop, zval *node) +{ + zval *value = prop.of(Z_OBJ_P(node)); + if (EXPECTED(value != NULL && Z_TYPE_P(value) != IS_UNDEF)) return value; + if (EG(exception) != NULL) return NULL; + if (value == NULL) { + zend_throw_error(NULL, "Undefined property: %s::$%s", ZSTR_VAL(Z_OBJCE_P(node)->name), prop.name); + return NULL; + } + zend_throw_error(NULL, "Typed property %s::$%s must not be accessed before initialization", ZSTR_VAL(Z_OBJCE_P(node)->name), prop.name); + return NULL; +} + +/* }}} */ + } // namespace namespace phpstanturbo { @@ -87,15 +114,52 @@ class ScalarHandler (void) storage; (void) nodeCallback; (void) context; - // a literal's type and its initializer context (file/namespace/class) are - // lexical - identical on every scope - so build the context once here. - zv::Val initializerExprContext = initializerExprContextFromScope(scope); - if (UNEXPECTED(initializerExprContext.isUndef())) return zv::Val(); + // a literal's type is lexical - identical on every scope - so compute it + // once, eagerly; anything but the plain scalars (the magic constants) + // keeps going through the initializer resolver + zv::Val type; + if (visitors::isInstanceOf(Z_OBJ_P(expr), PT_CLASS_SCALAR_STRING)) { + zval *value = nodeProp(pt_sh_scalar_string_value, expr); + if (UNEXPECTED(value == NULL)) return zv::Val(); + if (EXPECTED(Z_TYPE_P(value) == IS_STRING)) { + zval out; + if (UNEXPECTED(!pt_constant_string_type_new(&out, Z_STR_P(value), false))) return zv::Val(); + type = zv::Val::adopt(out); + } else { + type = pt_type_new_ce(pt_ce_constant_string_type, 1, value); + } + } else if (visitors::isInstanceOf(Z_OBJ_P(expr), PT_CLASS_SCALAR_INT)) { + zval *value = nodeProp(pt_sh_scalar_int_value, expr); + if (UNEXPECTED(value == NULL)) return zv::Val(); + if (EXPECTED(Z_TYPE_P(value) == IS_LONG)) { + zval out; + if (UNEXPECTED(!pt_constant_integer_type_new(&out, Z_LVAL_P(value)))) return zv::Val(); + type = zv::Val::adopt(out); + } else { + type = pt_type_new_ce(pt_ce_constant_integer_type, 1, value); + } + } else if (visitors::isInstanceOf(Z_OBJ_P(expr), PT_CLASS_SCALAR_FLOAT)) { + zval *value = nodeProp(pt_sh_scalar_float_value, expr); + if (UNEXPECTED(value == NULL)) return zv::Val(); + if (EXPECTED(Z_TYPE_P(value) == IS_DOUBLE)) { + zval out; + if (UNEXPECTED(!pt_constant_float_type_new(&out, Z_DVAL_P(value)))) return zv::Val(); + type = zv::Val::adopt(out); + } else { + type = pt_type_new_ce(pt_ce_constant_float_type, 1, value); + } + } else { + zv::Val initializerExprContext = initializerExprContextFromScope(scope); + if (UNEXPECTED(initializerExprContext.isUndef())) return zv::Val(); + type = initializerExprType(OBJ_PROP_NUM(self, slots::initializerExprTypeResolver), expr, initializerExprContext.raw()); + if (UNEXPECTED(type.isUndef())) return zv::Val(); + } - zv::Val typeCallback = pt_native_closure(&typeCallbackBody, self, expr, initializerExprContext.raw()); zv::Val specifyTypesCallback = pt_specified_types_empty_specify_callback(); if (UNEXPECTED(specifyTypesCallback.isUndef())) return zv::Val(); - pt_expression_result_args args(scope, scope, expr, false, false, NULL, NULL, typeCallback.raw(), specifyTypesCallback.raw()); + pt_expression_result_args args(scope, scope, expr, false, false, NULL, NULL, NULL, specifyTypesCallback.raw()); + args.withType(type.raw()); + args.withNativeType(type.raw()); return pt_expression_result_create(OBJ_PROP_NUM(self, slots::expressionResultFactory), args); } @@ -107,18 +171,6 @@ class ScalarHandler private: zend_object *self; - - /* fn () => $this->initializerExprTypeResolver->getType($expr, $initializerExprContext) - * — captures: $this, $expr, $initializerExprContext */ - static void typeCallbackBody(zval *captures, uint32_t argc, zval *argv, zval *return_value) - { - (void) argc; - (void) argv; - zval *handler = &captures[0]; - zv::Val type = initializerExprType(OBJ_PROP_NUM(Z_OBJ_P(handler), slots::initializerExprTypeResolver), &captures[1], &captures[2]); - if (UNEXPECTED(type.isUndef())) return; - type.intoReturnValue(return_value); - } }; } // namespace phpstanturbo diff --git a/turbo-ext/src/generated/ArrayHandler.h b/turbo-ext/src/generated/ArrayHandler.h index 57586518ce9..d33413f3499 100644 --- a/turbo-ext/src/generated/ArrayHandler.h +++ b/turbo-ext/src/generated/ArrayHandler.h @@ -53,17 +53,19 @@ inline constexpr char strings[] = "PHPStan\\Analyser\\ExpressionResult\0" /* 407 */ "processExpr\0" /* 441 */ "PhpParser\\Node\\Expr\\Array_\0" /* 453 */ - "itemResults\0" /* 480 */ - "result\0" /* 492 */ - "PHPStan\\Dependency\\Dependencies\0" /* 499 */ - "getCallableDependencies\0" /* 531 */ - "collectAbsorbedItems\0" /* 555 */ - "type\0" /* 576 */ - "PHPStan\\Type\\Type\0" /* 581 */ - "getExpectedArrayType\0" /* 599 */ - "arrayType\0" /* 620 */ - "keyType\0" /* 630 */ - "getExpectedValueType"; /* 638 */ + "itemTypes\0" /* 480 */ + "result\0" /* 490 */ + "PHPStan\\Dependency\\Dependencies\0" /* 497 */ + "getCallableDependencies\0" /* 529 */ + "collectAbsorbedItems\0" /* 553 */ + "type\0" /* 574 */ + "PHPStan\\Type\\Type\0" /* 579 */ + "getExpectedArrayType\0" /* 597 */ + "arrayType\0" /* 618 */ + "keyType\0" /* 628 */ + "getExpectedValueType\0" /* 636 */ + "array\0" /* 657 */ + "hasArrayReference"; /* 663 */ inline constexpr reg::PackedArg args[] = { reg::packed(0, 0, 28), /* __construct $initializerExprTypeResolver */ reg::packed(75, 0, 99), /* __construct $expressionResultFactory */ @@ -79,18 +81,20 @@ inline constexpr reg::PackedArg args[] = { reg::packed(177, 0, 407), /* processExpr return */ reg::packed(265, 0, 271), /* getCallableDependencies $scope */ reg::packed(152, 0, 453), /* getCallableDependencies $expr */ - reg::packed(480, MAY_BE_ARRAY), /* getCallableDependencies $itemResults */ - reg::packed(492, 0, 407), /* getCallableDependencies $result */ - reg::packed(177, MAY_BE_NULL, 499), /* getCallableDependencies return */ + reg::packed(480, MAY_BE_ARRAY), /* getCallableDependencies $itemTypes */ + reg::packed(490, 0, 407), /* getCallableDependencies $result */ + reg::packed(177, MAY_BE_NULL, 497), /* getCallableDependencies return */ reg::packed(152, 0, 453), /* collectAbsorbedItems $expr */ - reg::packed(480, MAY_BE_ARRAY), /* collectAbsorbedItems $itemResults */ + reg::packed(480, MAY_BE_ARRAY), /* collectAbsorbedItems $itemTypes */ reg::packed(265, 0, 271), /* collectAbsorbedItems $scope */ reg::packed(177, 0, 271), /* collectAbsorbedItems return */ - reg::packed(576, MAY_BE_NULL, 581), /* getExpectedArrayType $type */ - reg::packed(177, MAY_BE_NULL, 581), /* getExpectedArrayType return */ - reg::packed(620, MAY_BE_NULL, 581), /* getExpectedValueType $arrayType */ - reg::packed(630, 0, 581), /* getExpectedValueType $keyType */ - reg::packed(177, MAY_BE_NULL, 581), /* getExpectedValueType return */ + reg::packed(574, MAY_BE_NULL, 579), /* getExpectedArrayType $type */ + reg::packed(177, MAY_BE_NULL, 579), /* getExpectedArrayType return */ + reg::packed(618, MAY_BE_NULL, 579), /* getExpectedValueType $arrayType */ + reg::packed(628, 0, 579), /* getExpectedValueType $keyType */ + reg::packed(177, MAY_BE_NULL, 579), /* getExpectedValueType return */ + reg::packed(657, 0, 453), /* hasArrayReference $array */ + reg::packed(177, MAY_BE_BOOL), /* hasArrayReference return */ }; using Sig = reg::Sig; } // namespace sigtab @@ -100,10 +104,11 @@ namespace sig { inline constexpr sigtab::Sig __construct = { { 140 /* __construct */, 2, 0, 2, reg::NoArg, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig supports = { { 178 /* supports */, 1, 2, 1, 3, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig processExpr = { { 441 /* processExpr */, 7, 4, 7, 11, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig getCallableDependencies = { { 531 /* getCallableDependencies */, 4, 12, 4, 16, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig collectAbsorbedItems = { { 555 /* collectAbsorbedItems */, 3, 17, 3, 20, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig getExpectedArrayType = { { 599 /* getExpectedArrayType */, 1, 21, 1, 22, ZEND_ACC_PRIVATE } }; -inline constexpr sigtab::Sig getExpectedValueType = { { 638 /* getExpectedValueType */, 2, 23, 2, 25, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig getCallableDependencies = { { 529 /* getCallableDependencies */, 4, 12, 4, 16, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig collectAbsorbedItems = { { 553 /* collectAbsorbedItems */, 3, 17, 3, 20, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig getExpectedArrayType = { { 597 /* getExpectedArrayType */, 1, 21, 1, 22, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig getExpectedValueType = { { 636 /* getExpectedValueType */, 2, 23, 2, 25, ZEND_ACC_PRIVATE } }; +inline constexpr sigtab::Sig hasArrayReference = { { 663 /* hasArrayReference */, 1, 26, 1, 27, ZEND_ACC_PRIVATE | ZEND_ACC_STATIC } }; } // namespace sig } // namespace ptdecl::ArrayHandler diff --git a/turbo-ext/src/generated/ExpressionResultStorage.h b/turbo-ext/src/generated/ExpressionResultStorage.h index ca735826a60..04dc060a8bc 100644 --- a/turbo-ext/src/generated/ExpressionResultStorage.h +++ b/turbo-ext/src/generated/ExpressionResultStorage.h @@ -43,11 +43,12 @@ inline constexpr char strings[] = "PHPStan\\Analyser\\ExpressionResult\0" /* 125 */ "storeExpressionResult\0" /* 159 */ "findExpressionResult\0" /* 181 */ - "closure\0" /* 202 */ - "PhpParser\\Node\\Expr\\Closure\0" /* 210 */ - "walk\0" /* 238 */ - "storeByRefInvocationWalk\0" /* 243 */ - "findByRefInvocationWalks"; /* 268 */ + "removeExpressionResult\0" /* 202 */ + "closure\0" /* 225 */ + "PhpParser\\Node\\Expr\\Closure\0" /* 233 */ + "walk\0" /* 261 */ + "storeByRefInvocationWalk\0" /* 266 */ + "findByRefInvocationWalks"; /* 291 */ inline constexpr reg::PackedArg args[] = { reg::packed(12, 0, 13), /* duplicate return */ reg::packed(64, 0, 13), /* mergeResults $other */ @@ -57,10 +58,12 @@ inline constexpr reg::PackedArg args[] = { reg::packed(12, MAY_BE_VOID), /* storeExpressionResult return */ reg::packed(83, 0, 88), /* findExpressionResult $expr */ reg::packed(12, MAY_BE_NULL, 125), /* findExpressionResult return */ - reg::packed(202, 0, 210), /* storeByRefInvocationWalk $closure */ - reg::packed(238, MAY_BE_ARRAY), /* storeByRefInvocationWalk $walk */ + reg::packed(83, 0, 88), /* removeExpressionResult $expr */ + reg::packed(12, MAY_BE_VOID), /* removeExpressionResult return */ + reg::packed(225, 0, 233), /* storeByRefInvocationWalk $closure */ + reg::packed(261, MAY_BE_ARRAY), /* storeByRefInvocationWalk $walk */ reg::packed(12, MAY_BE_VOID), /* storeByRefInvocationWalk return */ - reg::packed(202, 0, 210), /* findByRefInvocationWalks $closure */ + reg::packed(225, 0, 233), /* findByRefInvocationWalks $closure */ reg::packed(12, MAY_BE_ARRAY), /* findByRefInvocationWalks return */ }; using Sig = reg::Sig; @@ -73,8 +76,9 @@ inline constexpr sigtab::Sig duplicate = { { 54 /* duplicate */, 0, 0, 0, 0, ZEN inline constexpr sigtab::Sig mergeResults = { { 70 /* mergeResults */, 1, 1, 1, 2, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig storeExpressionResult = { { 159 /* storeExpressionResult */, 2, 3, 2, 5, ZEND_ACC_PUBLIC } }; inline constexpr sigtab::Sig findExpressionResult = { { 181 /* findExpressionResult */, 1, 6, 1, 7, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig storeByRefInvocationWalk = { { 243 /* storeByRefInvocationWalk */, 2, 8, 2, 10, ZEND_ACC_PUBLIC } }; -inline constexpr sigtab::Sig findByRefInvocationWalks = { { 268 /* findByRefInvocationWalks */, 1, 11, 1, 12, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig removeExpressionResult = { { 202 /* removeExpressionResult */, 1, 8, 1, 9, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig storeByRefInvocationWalk = { { 266 /* storeByRefInvocationWalk */, 2, 10, 2, 12, ZEND_ACC_PUBLIC } }; +inline constexpr sigtab::Sig findByRefInvocationWalks = { { 291 /* findByRefInvocationWalks */, 1, 13, 1, 14, ZEND_ACC_PUBLIC } }; } // namespace sig } // namespace ptdecl::ExpressionResultStorage diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index 1090ddfb29c..c477a8b2514 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -2364,6 +2364,7 @@ zv::Val pt_expression_result_on_non_nullability_deviced_scopes(zval *result, zva * method left pending) and $stack->push($storage) / pop(); false = pending * exception */ void pt_expression_result_storage_store(zval *storage, zval *expr, zval *expressionResult); +[[nodiscard]] bool pt_expression_result_storage_remove(zval *storage, zval *expr); [[nodiscard]] bool pt_expression_result_storage_stack_push(zval *stack, zval *storage); [[nodiscard]] bool pt_expression_result_storage_stack_pop(zval *stack); diff --git a/turbo-ext/src/zv.h b/turbo-ext/src/zv.h index 72ec560d599..d0e03bca366 100644 --- a/turbo-ext/src/zv.h +++ b/turbo-ext/src/zv.h @@ -389,6 +389,14 @@ class ArrRef : public Ref Z_TRY_ADDREF_P(borrowed.raw()); zend_hash_index_update(table(), index, borrowed.raw()); } + + /* unset($a[$index]); separates a shared table first (writes through the + * slot), releases a removed value */ + void unsetIndex(zend_ulong index) + { + SEPARATE_ARRAY(z); + zend_hash_index_del(table(), index); + } }; /* Owned PHP array under construction. diff --git a/turbo-ext/tests/smoke.php b/turbo-ext/tests/smoke.php index 9df729e25f5..a6a131379ad 100644 --- a/turbo-ext/tests/smoke.php +++ b/turbo-ext/tests/smoke.php @@ -4285,8 +4285,10 @@ public function create( } check(($hhResults['php']['variable / top']['type'] ?? null) === 'int' && ($hhResults['php']['dynamic name / top']['flow']['kind'] ?? null) === 'sequence', 'ScalarHandler/VariableHandler: the fixture resolves a variable and walks a dynamic name: ' . json_encode([$hhResults['php']['variable / top'] ?? null, $hhResults['php']['dynamic name / top'] ?? null])); // a native closure is not serializable and has no body when userland creates one +// (the closure comes from the native VariableHandler: the ScalarHandler's +// result carries a precomputed type and no typeCallback since 2.3.x) try { - serialize((new \ReflectionProperty(\PHPStan\Analyser\ExpressionResult::class, 'typeCallback'))->getValue((new \PHPStanTurbo\ScalarHandler($hhInitializer, $hhFactory))->processExpr($hhNodeScopeResolver, $hhStmt, $hhExprs['int'], $hhScope, new \PHPStan\Analyser\ExpressionResultStorage(), new \PHPStan\Analyser\NoopNodeCallback(), $hhContexts['top']))); + serialize((new \ReflectionProperty(\PHPStan\Analyser\ExpressionResult::class, 'typeCallback'))->getValue((new \PHPStanTurbo\VariableHandler($hhNativeFactory, $hhDefault, $hhIdentical, $hhInitializer))->processExpr($hhNodeScopeResolver, $hhStmt, $hhExprs['variable'], $hhScope, new \PHPStan\Analyser\ExpressionResultStorage(), new \PHPStan\Analyser\NoopNodeCallback(), $hhContexts['top']))); check(false, 'NativeClosure: serialize() must refuse'); } catch (\Exception $e) { check(str_contains($e->getMessage(), 'is not allowed'), 'NativeClosure: serialize() message: ' . $e->getMessage()); From 80f80c67e9de0536cf66ae8b4c15b35936d968d1 Mon Sep 17 00:00:00 2001 From: Alfred Bez <1001186+alfredbez@users.noreply.github.com> Date: Sun, 11 Oct 2026 15:56:09 +0000 Subject: [PATCH 3/6] Bump expected turbo version --- src/Turbo/TurboExtensionEnabler.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index 22991812fa2..7adf4fb9f82 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -33,7 +33,7 @@ final class TurboExtensionEnabler { - public const EXPECTED_EXTENSION_VERSION = '1cb5faf'; + public const EXPECTED_EXTENSION_VERSION = 'c469421'; private static bool $active = false; From 00e0d76e0adea9f3e655c2ea54bfed06fbf24043 Mon Sep 17 00:00:00 2001 From: Alfred Bez <1001186+alfredbez@users.noreply.github.com> Date: Sun, 11 Oct 2026 17:23:28 +0000 Subject: [PATCH 4/6] Test the dependency contract of a callable array literal A [Foo::class, 'method'] array makes its file depend on what calling it returns, so a change of the method's return type re-analyses it; arrays whose first item is not definitely a class-string (a maybe-class-string, an object) add no such dependency. --- .../ArrayCallableDependenciesTest.php | 41 +++++++++++++++++++ .../data/array-callable-deps/factory.php | 19 +++++++++ .../data/array-callable-deps/result.php | 7 ++++ .../array-callable-deps/user-definite.php | 11 +++++ .../data/array-callable-deps/user-maybe.php | 32 +++++++++++++++ 5 files changed, 110 insertions(+) create mode 100644 tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php create mode 100644 tests/PHPStan/Analyser/data/array-callable-deps/factory.php create mode 100644 tests/PHPStan/Analyser/data/array-callable-deps/result.php create mode 100644 tests/PHPStan/Analyser/data/array-callable-deps/user-definite.php create mode 100644 tests/PHPStan/Analyser/data/array-callable-deps/user-maybe.php diff --git a/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php b/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php new file mode 100644 index 00000000000..fe9b866c26e --- /dev/null +++ b/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php @@ -0,0 +1,41 @@ +getByType(FileHelper::class); + $dataDir = $fileHelper->normalizePath(__DIR__ . '/data/array-callable-deps'); + $resultFile = $dataDir . '/result.php'; + $definiteFile = $dataDir . '/user-definite.php'; + $maybeFile = $dataDir . '/user-maybe.php'; + + $analyser = self::getContainer()->getByType(Analyser::class); + $analyserResult = $analyser->analyse([ + $definiteFile, + $maybeFile, + $dataDir . '/factory.php', + $resultFile, + ]); + + $dependencies = $analyserResult->getDependencies(); + $this->assertNotNull($dependencies); + $this->assertArrayHasKey($definiteFile, $dependencies); + $this->assertArrayHasKey($maybeFile, $dependencies); + $this->assertContains($resultFile, $dependencies[$definiteFile]); + $this->assertNotContains($resultFile, $dependencies[$maybeFile]); + } + +} diff --git a/tests/PHPStan/Analyser/data/array-callable-deps/factory.php b/tests/PHPStan/Analyser/data/array-callable-deps/factory.php new file mode 100644 index 00000000000..b111fe742df --- /dev/null +++ b/tests/PHPStan/Analyser/data/array-callable-deps/factory.php @@ -0,0 +1,19 @@ +|int $classOrInt + */ +function maybeClassString($classOrInt): void +{ + $cb = [$classOrInt, 'create']; +} + +/** + * The first item is only maybe a class-string: no callable dependency. + * + * @param class-string|OtherFactory $classOrObject + */ +function maybeClassStringOrObject($classOrObject): void +{ + $cb = [$classOrObject, 'create']; +} + +/** + * An object-first array is not a `[Foo::class, 'method']` callable: no + * callable dependency. + */ +function objectFirst(Factory $factory): void +{ + $cb = [$factory, 'create']; +} From 4396ff6e59d17bb10585c14c62a3327b204ccf93 Mon Sep 17 00:00:00 2001 From: Alfred Bez <1001186+alfredbez@users.noreply.github.com> Date: Sun, 11 Oct 2026 19:47:43 +0000 Subject: [PATCH 5/6] test: cover callable dependencies for PHP 7 Configure the target PHP version, not the test runtime, to expose class-string|object callable dependencies and detect the escaped mutant. --- tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php b/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php index fe9b866c26e..5d26ba3490e 100644 --- a/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php +++ b/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php @@ -14,6 +14,13 @@ final class ArrayCallableDependenciesTest extends PHPStanTestCase { + public static function getAdditionalConfigFiles(): array + { + // On PHP 7, a class-string|object pair can have a known callable return + // type, so skipping its dependency must come from the class-string guard. + return [__DIR__ . '/nodeScopeResolverPhp7.neon']; + } + public function testCallableArrayDependencies(): void { $fileHelper = self::getContainer()->getByType(FileHelper::class); From 2c12dac5e4f8626c8a7cfc0b4bb9c5ce48d37c96 Mon Sep 17 00:00:00 2001 From: Alfred Bez <1001186+alfredbez@users.noreply.github.com> Date: Mon, 12 Oct 2026 05:45:41 +0000 Subject: [PATCH 6/6] test: normalize complete dependency fixture paths --- tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php b/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php index 5d26ba3490e..f6c68cf9140 100644 --- a/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php +++ b/tests/PHPStan/Analyser/ArrayCallableDependenciesTest.php @@ -25,15 +25,15 @@ public function testCallableArrayDependencies(): void { $fileHelper = self::getContainer()->getByType(FileHelper::class); $dataDir = $fileHelper->normalizePath(__DIR__ . '/data/array-callable-deps'); - $resultFile = $dataDir . '/result.php'; - $definiteFile = $dataDir . '/user-definite.php'; - $maybeFile = $dataDir . '/user-maybe.php'; + $resultFile = $fileHelper->normalizePath($dataDir . '/result.php'); + $definiteFile = $fileHelper->normalizePath($dataDir . '/user-definite.php'); + $maybeFile = $fileHelper->normalizePath($dataDir . '/user-maybe.php'); $analyser = self::getContainer()->getByType(Analyser::class); $analyserResult = $analyser->analyse([ $definiteFile, $maybeFile, - $dataDir . '/factory.php', + $fileHelper->normalizePath($dataDir . '/factory.php'), $resultFile, ]);