From 3a9b59849eac20504a6a2406274fc64a5e96aa4c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:30 +0200 Subject: [PATCH 01/32] Accept precomputed operand types in RicherScopeGetTypeHelper getIdenticalResult() and getNotIdenticalResult() gain optional NodeScopeResolver and left/right Type parameters so inside-out narrowing callbacks can pass the operand types they already computed instead of having the helper re-price both sides through the scope. Rules keep calling the two-argument form. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/RicherScopeGetTypeHelper.php | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/Analyser/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php index 132c1875809..699cf35d7f0 100644 --- a/src/Analyser/RicherScopeGetTypeHelper.php +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -10,6 +10,7 @@ use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Type; use PHPStan\Type\TypeResult; use function is_string; @@ -27,7 +28,7 @@ public function __construct( /** * @return TypeResult */ - public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult + public function getIdenticalResult(Scope $scope, Identical $expr, ?NodeScopeResolver $nodeScopeResolver = null, ?Type $leftType = null, ?Type $rightType = null): TypeResult { if ( $expr->left instanceof Variable @@ -39,8 +40,16 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult return new TypeResult(new ConstantBooleanType(true), []); } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + // operand types passed from inside-out callbacks (e.g. BinaryOp's + // typeCallback) come from the already computed ExpressionResults; + // $nodeScopeResolver reads them from storage instead of Scope::getType(); + // rules call this with neither (BC). + $leftType ??= $nodeScopeResolver !== null + ? $nodeScopeResolver->readTypeOfMaybeStored($expr->left, $scope->toMutatingScope()) + : $scope->getType($expr->left); + $rightType ??= $nodeScopeResolver !== null + ? $nodeScopeResolver->readTypeOfMaybeStored($expr->right, $scope->toMutatingScope()) + : $scope->getType($expr->right); if ( ( @@ -78,9 +87,9 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult /** * @return TypeResult */ - public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr): TypeResult + public function getNotIdenticalResult(Scope $scope, Node\Expr\BinaryOp\NotIdentical $expr, ?NodeScopeResolver $nodeScopeResolver = null, ?Type $leftType = null, ?Type $rightType = null): TypeResult { - $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right)); + $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right), $nodeScopeResolver, $leftType, $rightType); $identicalType = $identicalResult->type; if ($identicalType instanceof ConstantBooleanType) { return new TypeResult(new ConstantBooleanType(!$identicalType->getValue()), $identicalResult->reasons); From 3832b5ec4ad7fcf7a04a8783577eb10e9141724d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:31 +0200 Subject: [PATCH 02/32] Let ExpressionResult own its expression's type and narrowing ExpressionResult becomes the single carrier of what a walked expression means: a per-flavour memoized typeCallback, a specifyTypesCallback memoized per (context, flavour), an optional createTypesCallback (the inside-out counterpart of TypeSpecifier::create()), and eager type/nativeType slots for handlers that already built both flavours. Truthy/falsey scopes are derived from the result's own specified types, with explicit overrides replacing the scope callbacks. Void projection moves here too: results keep the raw type and project void to null at the value-read boundary (getKeepVoidType() is the opt-out), replacing VoidToNullTypeTransformer and the keepVoid node attribute. Position awareness (getTypeOnScope(), answersOnScope(), askScopeVariableStateMatches(), takeReadVariableStateSnapshot()) lets consumers decide whether a stored result still answers on the asking scope. Test expectations follow the void change: a phpdoc @return void read as a value is now null. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- .../Helper/VoidToNullTypeTransformer.php | 23 - src/Analyser/ExpressionResult.php | 578 +++++++++++++++++- src/Analyser/ExpressionResultFactory.php | 15 +- src/Analyser/ReadVariableStateSnapshot.php | 62 ++ src/Analyser/SpecifiedTypes.php | 178 ++---- .../methodPhpDocs-recursive-trait-defined.php | 2 +- .../data/methodPhpDocs-trait-defined.php | 2 +- .../Analyser/nsrt/closure-return-type.php | 4 +- .../nsrt/functionPhpDocs-phanPrefix.php | 2 +- .../nsrt/functionPhpDocs-phpstanPrefix.php | 2 +- .../nsrt/functionPhpDocs-psalmPrefix.php | 2 +- .../PHPStan/Analyser/nsrt/functionPhpDocs.php | 2 +- ...hpDocs-inheritdoc-without-curly-braces.php | 2 +- .../nsrt/method-phpDocs-inheritdoc.php | 2 +- .../methodPhpDocs-implicitInheritance.php | 2 +- .../nsrt/methodPhpDocs-phanPrefix.php | 2 +- .../nsrt/methodPhpDocs-phpstanPrefix.php | 2 +- .../nsrt/methodPhpDocs-psalmPrefix.php | 2 +- ...missing-closure-native-return-typehint.php | 4 +- .../PHPStan/Analyser/nsrt/mixed-typehint.php | 2 +- tests/PHPStan/Reflection/data/mixedType.php | 2 +- .../Rules/Functions/CallCallablesRuleTest.php | 2 +- .../WrongVariableNameInVarTagRuleTest.php | 14 +- 23 files changed, 700 insertions(+), 208 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Helper/VoidToNullTypeTransformer.php create mode 100644 src/Analyser/ReadVariableStateSnapshot.php diff --git a/src/Analyser/ExprHandler/Helper/VoidToNullTypeTransformer.php b/src/Analyser/ExprHandler/Helper/VoidToNullTypeTransformer.php deleted file mode 100644 index c718f5e8209..00000000000 --- a/src/Analyser/ExprHandler/Helper/VoidToNullTypeTransformer.php +++ /dev/null @@ -1,23 +0,0 @@ -getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME) === true) { - return $type; - } - - return TypeTraverser::map($type, new VoidToNullTraverser()); - } - -} diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 0ec7eb15979..85382970b39 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -2,31 +2,72 @@ namespace PHPStan\Analyser; +use Override; +use PhpParser\Node; use PhpParser\Node\Expr; +use PhpParser\NodeTraverser; +use PhpParser\NodeVisitor; +use PhpParser\NodeVisitorAbstract; +use PHPStan\Analyser\Traverser\VoidToNullTraverser; +use PHPStan\DependencyInjection\AutowiredExtensions; +use PHPStan\DependencyInjection\ExtensionsCollection; use PHPStan\DependencyInjection\GenerateFactory; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\ExpressionTypeResolverExtension; use PHPStan\Type\Type; +use PHPStan\Type\TypeTraverser; +use PHPStan\Type\TypeUtils; +use PHPStan\Type\UnionType; +use function array_keys; +use function is_string; +use function spl_object_id; #[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult { - /** @var (callable(): MutatingScope)|null */ - private $truthyScopeCallback; + /** @var (callable(bool): Type)|null */ + private $typeCallback; - private ?MutatingScope $truthyScope = null; + /** @var callable(TypeSpecifierContext, bool): SpecifiedTypes */ + private $specifyTypesCallback; + + /** @var (callable(Type, TypeSpecifierContext, bool): SpecifiedTypes)|null */ + private $createTypesCallback; + + /** @var array */ + private array $specifiedTypes = []; - /** @var (callable(): MutatingScope)|null */ - private $falseyScopeCallback; + private ?MutatingScope $truthyScope = null; private ?MutatingScope $falseyScope = null; + private ?Type $cachedType = null; + + private ?Type $cachedNativeType = null; + + private ?Type $resolvedType = null; + + private ?Type $resolvedNativeType = null; + + private ?Type $projectedType = null; + + private ?Type $projectedNativeType = null; + + /** @var list|null */ + private ?array $readVariableNames = null; + /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints - * @param (callable(): MutatingScope)|null $truthyScopeCallback - * @param (callable(): MutatingScope)|null $falseyScopeCallback + * @param (callable(bool): Type)|null $typeCallback + * @param callable(TypeSpecifierContext, bool): SpecifiedTypes $specifyTypesCallback + * @param (callable(Type, TypeSpecifierContext, bool): SpecifiedTypes)|null $createTypesCallback + * @param ExtensionsCollection $expressionTypeResolverExtensions */ public function __construct( + #[AutowiredExtensions(of: ExpressionTypeResolverExtension::class)] + private ExtensionsCollection $expressionTypeResolverExtensions, private MutatingScope $scope, private MutatingScope $beforeScope, private Expr $expr, @@ -34,14 +75,58 @@ public function __construct( private bool $isAlwaysTerminating, private array $throwPoints, private array $impurePoints, + ?callable $typeCallback, + callable $specifyTypesCallback, private bool $containsNullsafe = false, private ?IssetabilityDescriptor $issetabilityDescriptor = null, - ?callable $truthyScopeCallback = null, - ?callable $falseyScopeCallback = null, + private ?MutatingScope $truthyScopeOverride = null, + private ?MutatingScope $falseyScopeOverride = null, + ?callable $createTypesCallback = null, + private ?Type $type = null, + private ?Type $nativeType = null, ) { - $this->truthyScopeCallback = $truthyScopeCallback; - $this->falseyScopeCallback = $falseyScopeCallback; + // A precomputed type and a lazy typeCallback are mutually exclusive, but + // exactly one of them must be set - a result with neither cannot answer its + // own type. phpdoc and native types are precomputed together or not at all. + if ($typeCallback !== null && $type !== null) { + throw new ShouldNotHappenException('ExpressionResult cannot have both a typeCallback and a precomputed type.'); + } + if ($typeCallback === null && $type === null) { + throw new ShouldNotHappenException('ExpressionResult must have either a precomputed type or a typeCallback.'); + } + if (($type === null) !== ($nativeType === null)) { + throw new ShouldNotHappenException('ExpressionResult type and nativeType must both be set or both be null.'); + } + + $this->typeCallback = $typeCallback; + $this->specifyTypesCallback = $specifyTypesCallback; + $this->createTypesCallback = $createTypesCallback; + } + + /** + * Turns the stored preliminary result (the type/specify callbacks published + * before the call handler's throw-point leg runs) into the final one in + * place: the resolved scope and the effects arrive, every memoized + * own-type/narrowing answer computed through the preliminary is carried + * over, and the truthy/falsey scopes derived from the preliminary scope + * are dropped. Equivalent to overwriting the stored result with a second + * object, minus the allocation and the lost memos. + * + * @param InternalThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + */ + public function finalize(MutatingScope $scope, bool $hasYield, bool $isAlwaysTerminating, array $throwPoints, array $impurePoints): self + { + $this->scope = $scope; + $this->hasYield = $hasYield; + $this->isAlwaysTerminating = $isAlwaysTerminating; + $this->throwPoints = $throwPoints; + $this->impurePoints = $impurePoints; + $this->truthyScope = null; + $this->falseyScope = null; + + return $this; } public function getScope(): MutatingScope @@ -76,7 +161,7 @@ public function containsNullsafe(): bool } /** - * The isset/empty/?? view of this expression evaluated at the given + * The fully-resolved isset/empty/?? view of this expression on the asking * scope: folds the chain descriptor, or builds a leaf resolution from the * expression's own type when it is not a chain link (e.g. a method-call-rooted * base like $this->getFoo()['x']). $useNativeTypes selects native vs phpdoc. @@ -95,12 +180,6 @@ public function getIssetabilityResolution(MutatingScope $scope, bool $useNativeT ); } - /** Prices this result's expression on the given scope in the requested flavour. */ - public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes): Type - { - return $useNativeTypes ? $scope->getNativeType($this->expr) : $scope->getType($this->expr); - } - /** * @return InternalThrowPoint[] */ @@ -123,12 +202,19 @@ public function getTruthyScope(): MutatingScope return $this->truthyScope; } - if ($this->truthyScopeCallback === null) { - return $this->truthyScope = $this->scope->filterByTruthyValue($this->expr); + // && is truthy only when the right operand was evaluated (on the left-truthy + // scope) and is itself truthy - that is exactly $rightResult->getTruthyScope(), + // which the handler passes as $truthyScopeOverride. It already carries the left + // operand's narrowing and the right operand's by-ref/side-effect definitions, + // and crucially does NOT re-apply the left narrowing on top of a scope where the + // right operand reassigned the narrowed variable (see bug-9400). + if ($this->truthyScopeOverride !== null) { + return $this->truthyScope = $this->truthyScopeOverride; } - $callback = $this->truthyScopeCallback; - return $this->truthyScope = $callback(); + return $this->truthyScope = $this->scope->applySpecifiedTypes( + $this->getSpecifiedTypes(TypeSpecifierContext::createTruthy(), $this->scope->nativeTypesPromoted), + ); } public function getFalseyScope(): MutatingScope @@ -137,12 +223,15 @@ public function getFalseyScope(): MutatingScope return $this->falseyScope; } - if ($this->falseyScopeCallback === null) { - return $this->falseyScope = $this->scope->filterByFalseyValue($this->expr); + // || is falsey only when the right operand was evaluated (on the left-falsey + // scope) and is itself falsey - that is exactly $rightResult->getFalseyScope(). + if ($this->falseyScopeOverride !== null) { + return $this->falseyScope = $this->falseyScopeOverride; } - $callback = $this->falseyScopeCallback; - return $this->falseyScope = $callback(); + return $this->falseyScope = $this->scope->applySpecifiedTypes( + $this->getSpecifiedTypes(TypeSpecifierContext::createFalsey(), $this->scope->nativeTypesPromoted), + ); } public function isAlwaysTerminating(): bool @@ -152,12 +241,445 @@ public function isAlwaysTerminating(): bool public function getType(): Type { - return $this->beforeScope->getType($this->expr); + if ($this->type !== null) { + return $this->type; + } + + if ($this->cachedType !== null) { + return $this->cachedType; + } + + foreach ($this->expressionTypeResolverExtensions->getAll() as $extension) { + $type = $extension->getType($this->expr, $this->beforeScope); + if ($type !== null) { + return $this->cachedType = $type; + } + } + + if ($this->hasOwnLazyResolution() && !$this->hasTrackedExpressionType($this->beforeScope)) { + return $this->cachedType = $this->resolveOwnType(false); + } + + // The guard above leaves only one way here: the expression is tracked on + // beforeScope (typeCallback is set but a holder wins). Read the holder + // directly instead of re-entering MutatingScope::getType(). + return $this->cachedType = $this->beforeScope->getTrackedExpressionType($this->expr); } public function getNativeType(): Type { - return $this->beforeScope->getNativeType($this->expr); + if ($this->nativeType !== null) { + return $this->nativeType; + } + + if ($this->cachedNativeType !== null) { + return $this->cachedNativeType; + } + + if ($this->hasOwnLazyResolution() && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { + return $this->cachedNativeType = $this->resolveOwnType(true); + } + + // Tracked native holder (getNativeType() promotes the scope, so its + // expressionTypes are the native ones) - read it directly. + return $this->cachedNativeType = $this->beforeScope->doNotTreatPhpDocTypesAsCertain()->getTrackedExpressionType($this->expr); + } + + /** + * The result's own raw type - the eager value or the memoized typeCallback, + * with no tracked-holder interference. The callback is a pure function of + * the flavour flag, so one memo slot per flavour is exact. + * + * A void-returning call keeps `void` here; the void->null projection every + * value read applies happens in resolveOwnType(). getKeepVoidType() reads + * this raw type so a void call used as a value (assigned, passed as an + * argument, a void match arm) is still seen as void by the rules that + * flag that misuse. + */ + private function resolveOwnRawType(bool $nativeTypesPromoted): Type + { + if ($nativeTypesPromoted) { + if ($this->nativeType !== null) { + return $this->nativeType; + } + if ($this->resolvedNativeType !== null) { + return $this->resolvedNativeType; + } + if ($this->typeCallback === null) { + throw new ShouldNotHappenException(); + } + + $resolvedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); + $this->resolvedNativeType = $resolvedNativeType; + $this->releaseTypeCallbackIfResolved(); + + return $resolvedNativeType; + } + + if ($this->type !== null) { + return $this->type; + } + if ($this->resolvedType !== null) { + return $this->resolvedType; + } + if ($this->typeCallback === null) { + throw new ShouldNotHappenException(); + } + + $resolvedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(false)); + $this->resolvedType = $resolvedType; + $this->releaseTypeCallbackIfResolved(); + + return $resolvedType; + } + + /** + * Once both flavours are memoized the callback can never be invoked again - + * dropping it releases its captured environment (child results, intermediate + * scopes) for refcount collection while the file is still being analysed. + */ + private function releaseTypeCallbackIfResolved(): void + { + if ($this->resolvedType === null || $this->resolvedNativeType === null) { + return; + } + + $this->typeCallback = null; + } + + /** + * The result's own type as a value: the raw type with `void` projected to + * `null` (a void expression evaluates to null). The projection used to live + * in the call handlers' return-type resolution; keeping it at this single + * read boundary lets one raw type serve both value reads and + * getKeepVoidType(). + */ + private function resolveOwnType(bool $nativeTypesPromoted): Type + { + if ($nativeTypesPromoted) { + return $this->projectedNativeType ??= $this->projectVoidToNull($this->resolveOwnRawType(true)); + } + + return $this->projectedType ??= $this->projectVoidToNull($this->resolveOwnRawType(false)); + } + + private function projectVoidToNull(Type $type): Type + { + // void only ever originates from a call return type; the overwhelmingly + // common non-void, non-union result skips the traverser entirely + if ($type->isVoid()->no() && !$type instanceof UnionType) { + return $type; + } + + return TypeTraverser::map($type, new VoidToNullTraverser()); + } + + /** + * The own type with `void` kept (not projected to null) - answers + * Scope::getKeepVoidType() from the stored result instead of re-processing + * the node with a keep-void marker. + */ + public function getKeepVoidType(bool $nativeTypesPromoted): Type + { + return $this->resolveOwnRawType($nativeTypesPromoted); + } + + /** + * A narrowed or ensured type tracked for the whole expression (e.g. the + * nullsafe handlers ensure `($x ?? null)` is not null before processing + * the chain) wins over recomputing the type - mirrors the tracked-holder + * early return in MutatingScope::resolveType(). Asking the scope is safe: + * its own early return answers from the holder without dispatching back. + */ + private function hasTrackedExpressionType(MutatingScope $scope): bool + { + return !$this->expr instanceof Expr\Variable + && !$this->expr instanceof Expr\Closure + && !$this->expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($this->expr)->yes(); + } + + /** + * Whether this result can answer its own type without asking the scope - + * either an eagerly computed value (e.g. a closure's ClosureType) or a + * typeCallback. The new-world resolution in MutatingScope gates on this. + */ + public function canResolveOwnType(): bool + { + return $this->type !== null || $this->hasOwnLazyResolution(); + } + + /** + * True while the typeCallback is alive or after it was released because both + * flavour memos are filled - either way the result answers its own type. + */ + private function hasOwnLazyResolution(): bool + { + return $this->typeCallback !== null || $this->resolvedType !== null; + } + + /** Evaluates this expression's narrowing on the given scope. */ + public function getSpecifiedTypesForScope(MutatingScope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + return $this->getSpecifiedTypes($context, $scope->nativeTypesPromoted); + } + + /** + * The expression's narrowing for the given context, computed at its own + * evaluation point (the flavour-mapped beforeScope) and memoized per + * (context, flavour). All state-dependent math lives in the symbolic + * SpecifiedTypes (alternative terms, holder recipes, deferred augments) + * and is evaluated by applySpecifiedTypes() against whichever scope the + * narrowing is applied to - so one memoized SpecifiedTypes serves every + * asking position. + */ + public function getSpecifiedTypes(TypeSpecifierContext $context, bool $nativeTypesPromoted = false): SpecifiedTypes + { + $key = (spl_object_id($context) << 1) | ($nativeTypesPromoted ? 1 : 0); + + return $this->specifiedTypes[$key] ??= ($this->specifyTypesCallback)($context, $nativeTypesPromoted); + } + + /** + * How a type constraint on this expression translates into narrowing + * entries - the inside-out counterpart of TypeSpecifier::create(). The + * handler that produced this result knows the structure: an assignment + * fans out to the assigned variable and the assigned expression + * (recursing through the assigned expression's own result), a coalesce + * delegates to its left side when the type rules the right side in or + * out. Returns null when the handler wired no createTypesCallback - the + * caller emits a single entry for the expression itself. + */ + public function getCreatedTypesForScope(MutatingScope $scope, Type $type, TypeSpecifierContext $context): ?SpecifiedTypes + { + return $this->getCreatedTypes($type, $context, $scope->nativeTypesPromoted); + } + + /** + * The narrowing entries a type constraint on this expression fans out to, + * computed at the expression's own evaluation point - the asking scope + * reduces to its flavour bit, like getSpecifiedTypes(). + */ + public function getCreatedTypes(Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted = false): ?SpecifiedTypes + { + if ($this->createTypesCallback === null) { + return null; + } + + return ($this->createTypesCallback)($type, $context, $nativeTypesPromoted); + } + + /** + * The type of this expression as the given scope sees it: a narrowed or + * ensured type the scope tracks for the whole expression wins over the + * result's own (position-time) type. For the deliberately scope-sensitive + * consumers - isset/empty/?? chain folding and the stored-result read in + * NodeScopeResolver - everything else reads getType()/getNativeType(). + */ + public function getTypeOnScope(MutatingScope $scope, bool $useNativeTypes): Type + { + $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + if ($this->type === null && $this->isScopeAuthoritative($readScope)) { + // the state read is a value read: resolve late-resolvable types and + // project void to null exactly like resolveOwnType() does + return $this->projectVoidToNull(TypeUtils::resolveLateResolvableTypes($readScope->getStateType($this->expr))); + } + + return $this->resolveOwnType($useNativeTypes); + } + + /** + * Whether getTypeOnScope() gives the correct answer at the given (foreign) + * position without re-pricing the expression there: the answer is + * position-independent (eager type), the scope owns it (tracked variable or + * expression - including narrowing and invalidation of this very + * expression), or nothing the expression reads changed since the walk. + * When this is false, the caller must reprocess the expression on the + * asking scope. + */ + public function answersOnScope(MutatingScope $scope, bool $useNativeTypes): bool + { + if ($this->type !== null) { + return true; + } + + $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + + return $this->isScopeAuthoritative($readScope) || $this->askScopeVariableStateMatches($scope, $useNativeTypes); + } + + /** + * Whether the given scope, not this result, owns the answer to "what is + * this expression here": narrowable expressions the scope knows (variables + * including $this and parameters, tracked fetches) and any expression the + * scope tracks a holder for (ensured non-nullability, remembered values). + * Evaluating this result's expression at a foreign position must read + * those from that position's state - the memoized walk-position type + * predates whatever narrowing or invalidation the scope carries. + */ + private function isScopeAuthoritative(MutatingScope $scope): bool + { + if ($this->expr instanceof Expr\Variable) { + return is_string($this->expr->name) && !$scope->hasVariableType($this->expr->name)->no(); + } + + return !$this->expr instanceof Expr\Closure + && !$this->expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($this->expr)->yes(); + } + + /** + * Whether the asking scope agrees with this result's evaluation position on + * every variable the expression reads. A counterfactual ask - an extension + * re-binding a variable (e.g. array_filter evaluating its callback body per + * constant element) and pricing a real node - must not be answered from the + * memoized walk-position type; the caller re-prices the node on the asking + * scope instead. + */ + /** The retained equivalent of askScopeVariableStateMatches() - see ReadVariableStateSnapshot. */ + public function takeReadVariableStateSnapshot(): ReadVariableStateSnapshot + { + if ($this->expr instanceof Expr\Closure || $this->expr instanceof Expr\ArrowFunction) { + return new ReadVariableStateSnapshot([]); + } + + $states = []; + $positionScope = $this->beforeScope; + $nativePositionScope = $positionScope->doNotTreatPhpDocTypesAsCertain(); + foreach ($this->getReadVariableNames() as $name) { + $knows = $positionScope->hasVariableType($name); + $nativeKnows = $nativePositionScope->hasVariableType($name); + $states[$name] = [ + $knows, + $knows->no() ? null : $positionScope->getVariableType($name), + $nativeKnows, + $nativeKnows->no() ? null : $nativePositionScope->getVariableType($name), + ]; + } + + return new ReadVariableStateSnapshot($states); + } + + public function askScopeVariableStateMatches(MutatingScope $scope, bool $useNativeTypes): bool + { + // same unpromoted position implies same promoted position - skip the + // flavour derivation for the common same-position ask + if ($scope === $this->beforeScope) { + return true; + } + // a closure's stored result IS its (by-ref converged) walk; re-walking + // it at a foreign position would re-run the whole convergence loop. Its + // body variables are not reads of the asking position, and the + // position-sensitive TYPE is computed by getClosureType at ask sites. + if ($this->expr instanceof Expr\Closure || $this->expr instanceof Expr\ArrowFunction) { + return true; + } + $names = $this->getReadVariableNames(); + if ($names === []) { + return true; + } + + $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + $positionScope = $useNativeTypes ? $this->beforeScope->doNotTreatPhpDocTypesAsCertain() : $this->beforeScope; + if ($readScope === $positionScope) { + return true; + } + + foreach ($names as $name) { + $askKnows = $readScope->hasVariableType($name); + $positionKnows = $positionScope->hasVariableType($name); + if ($askKnows->no() && $positionKnows->no()) { + continue; + } + if (!$askKnows->equals($positionKnows)) { + return false; + } + if (!$readScope->getVariableType($name)->equals($positionScope->getVariableType($name))) { + return false; + } + } + + return true; + } + + /** + * A copy of this result answering at a foreign ask position: the scopes are + * re-anchored to the asking scope so an on-demand walk consuming this + * answer threads ITS position onward, not the original walk's. The + * position-dependent branch-scope memos and overrides are dropped - they + * belong to the original position and derive from the ask scope on demand. + */ + public function atAskPosition(MutatingScope $scope): self + { + $clone = clone $this; + $clone->scope = $scope; + $clone->beforeScope = $scope; + $clone->truthyScope = null; + $clone->falseyScope = null; + $clone->truthyScopeOverride = null; + $clone->falseyScopeOverride = null; + $clone->cachedType = null; + $clone->cachedNativeType = null; + // a scope-authoritative expression's type is pinned eagerly from the ask + // position's state - the original callbacks capture the original + // position's scopes and would answer stale types (e.g. a variable + // receiver consumed on an ensured-non-null scope) + if ($this->type === null && $this->isScopeAuthoritative($scope)) { + $clone->type = $scope->getStateType($this->expr); + $clone->nativeType = $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($this->expr); + $clone->typeCallback = null; + $clone->resolvedType = null; + $clone->resolvedNativeType = null; + $clone->projectedType = null; + $clone->projectedNativeType = null; + } + + return $clone; + } + + /** + * @return list + */ + private function getReadVariableNames(): array + { + if ($this->readVariableNames !== null) { + return $this->readVariableNames; + } + + $visitor = new class extends NodeVisitorAbstract { + + /** @var array */ + public array $names = []; + + #[Override] + public function enterNode(Node $node): ?int + { + if ($node instanceof Expr\Variable && is_string($node->name) && $node->name !== 'this') { + $this->names[$node->name] = true; + } + // a closure body's variables live in its own scope - only the + // use() clause reads the enclosing position. Arrow functions + // capture implicitly and are traversed. + if ($node instanceof Expr\Closure) { + foreach ($node->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + $this->names[$use->var->name] = true; + } + + return NodeVisitor::DONT_TRAVERSE_CHILDREN; + } + + return null; + } + + }; + $traverser = new NodeTraverser(); + $traverser->addVisitor($visitor); + $traverser->traverse([$this->expr]); + + return $this->readVariableNames = array_keys($visitor->names); } } diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php index e49481036da..1741330494d 100644 --- a/src/Analyser/ExpressionResultFactory.php +++ b/src/Analyser/ExpressionResultFactory.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node\Expr; +use PHPStan\Type\Type; interface ExpressionResultFactory { @@ -10,8 +11,9 @@ interface ExpressionResultFactory /** * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints - * @param (callable(): MutatingScope)|null $truthyScopeCallback - * @param (callable(): MutatingScope)|null $falseyScopeCallback + * @param (callable(bool): Type)|null $typeCallback + * @param callable(TypeSpecifierContext, bool): SpecifiedTypes $specifyTypesCallback + * @param (callable(Type, TypeSpecifierContext, bool): SpecifiedTypes)|null $createTypesCallback */ public function create( MutatingScope $scope, @@ -21,10 +23,15 @@ public function create( bool $isAlwaysTerminating, array $throwPoints, array $impurePoints, + ?callable $typeCallback, + callable $specifyTypesCallback, bool $containsNullsafe = false, ?IssetabilityDescriptor $issetabilityDescriptor = null, - ?callable $truthyScopeCallback = null, - ?callable $falseyScopeCallback = null, + ?MutatingScope $truthyScopeOverride = null, + ?MutatingScope $falseyScopeOverride = null, + ?callable $createTypesCallback = null, + ?Type $type = null, + ?Type $nativeType = null, ): ExpressionResult; } diff --git a/src/Analyser/ReadVariableStateSnapshot.php b/src/Analyser/ReadVariableStateSnapshot.php new file mode 100644 index 00000000000..3e1c7c289a1 --- /dev/null +++ b/src/Analyser/ReadVariableStateSnapshot.php @@ -0,0 +1,62 @@ + $variableStates + */ + public function __construct(private array $variableStates) + { + } + + public function matches(MutatingScope $askScope): bool + { + if ($this->variableStates === []) { + return true; + } + + $nativeAskScope = $askScope->doNotTreatPhpDocTypesAsCertain(); + foreach ($this->variableStates as $name => [$knows, $type, $nativeKnows, $nativeType]) { + if ( + !$this->flavourMatches($askScope, (string) $name, $knows, $type) + || !$this->flavourMatches($nativeAskScope, (string) $name, $nativeKnows, $nativeType) + ) { + return false; + } + } + + return true; + } + + private function flavourMatches(MutatingScope $scope, string $name, TrinaryLogic $positionKnows, ?Type $positionType): bool + { + $askKnows = $scope->hasVariableType($name); + if ($askKnows->no() && $positionKnows->no()) { + return true; + } + if (!$askKnows->equals($positionKnows)) { + return false; + } + if ($positionType === null) { + return false; + } + + return $scope->getVariableType($name)->equals($positionType); + } + +} diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index ed8e09a7711..f01dbbe0e77 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use Closure; use PhpParser\Node\Expr; use PHPStan\Type\NeverType; use PHPStan\Type\Type; @@ -13,6 +14,9 @@ final class SpecifiedTypes { + /** @var (Closure(TypeSpecifierContext, bool): self)|null */ + private static ?Closure $emptySpecifyCallback = null; + /** * Cross-producing alternative forms doubles the term count per conjunction; * past this many terms the entry is widened to a single covering term. @@ -68,6 +72,18 @@ public function __construct( { } + /** + * A shared no-narrowing specify callback for results whose expression never + * narrows anything (literals, virtual write nodes) - one process-wide + * closure instead of one allocation per created ExpressionResult. + * + * @return Closure(TypeSpecifierContext, bool): self + */ + public static function emptySpecifyCallback(): Closure + { + return self::$emptySpecifyCallback ??= static fn (): self => new self(); + } + /** * Normally, $sureTypes in truthy context are used to intersect with the pre-existing type. * And $sureNotTypes are used to remove type from the pre-existing type. @@ -191,22 +207,6 @@ public function withoutConditionalExpressionHolders(): self return $self; } - /** - * A copy of this with the other's alternative-form entries - for the - * composition tails that rebuild a SpecifiedTypes from the sure/sure-not - * slots and must not drop the merged alternatives. - */ - public function withAlternativeTypesOf(self $other): self - { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $other->alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->rootExpr = $this->rootExpr; - - return $self; - } - public function shouldOverwrite(): bool { return $this->overwrite; @@ -251,7 +251,7 @@ public function intersectWith(SpecifiedTypes $other): self $sureTypeUnion = []; $sureNotTypeUnion = []; $alternativeUnion = []; - $rootExpr = self::mergeRootExpr($this->rootExpr, $other->rootExpr); + $rootExpr = $this->mergeRootExpr($this->rootExpr, $other->rootExpr); $keys = []; foreach ([$this->sureTypes, $this->sureNotTypes, $this->alternativeTypes, $other->sureTypes, $other->sureNotTypes, $other->alternativeTypes] as $map) { @@ -418,6 +418,37 @@ private static function conjoinTerms(array $terms, array $otherTerms): array return $conjoined; } + /** + * @param list $terms + * @return list + */ + private static function dedupeTerms(array $terms): array + { + $deduped = []; + foreach ($terms as [$sure, $subtract]) { + foreach ($deduped as [$seenSure, $seenSubtract]) { + if (($sure === null) !== ($seenSure === null)) { + continue; + } + if (($subtract === null) !== ($seenSubtract === null)) { + continue; + } + if ($sure !== null && $seenSure !== null && !$sure->equals($seenSure)) { + continue; + } + if ($subtract !== null && $seenSubtract !== null && !$subtract->equals($seenSubtract)) { + continue; + } + + continue 2; + } + + $deduped[] = [$sure, $subtract]; + } + + return $deduped; + } + /** * A single term covering the union of all of them - the safety net that * stops a chain of conjoined alternative forms from growing its @@ -452,43 +483,12 @@ private static function widenTerms(array $terms): array ]; } - /** - * @param list $terms - * @return list - */ - private static function dedupeTerms(array $terms): array - { - $deduped = []; - foreach ($terms as [$sure, $subtract]) { - foreach ($deduped as [$seenSure, $seenSubtract]) { - if (($sure === null) !== ($seenSure === null)) { - continue; - } - if (($subtract === null) !== ($seenSubtract === null)) { - continue; - } - if ($sure !== null && $seenSure !== null && !$sure->equals($seenSure)) { - continue; - } - if ($subtract !== null && $seenSubtract !== null && !$subtract->equals($seenSubtract)) { - continue; - } - - continue 2; - } - - $deduped[] = [$sure, $subtract]; - } - - return $deduped; - } - /** @api */ public function unionWith(SpecifiedTypes $other): self { $sureTypeUnion = $this->sureTypes + $other->sureTypes; $sureNotTypeUnion = $this->sureNotTypes + $other->sureNotTypes; - $rootExpr = self::mergeRootExpr($this->rootExpr, $other->rootExpr); + $rootExpr = $this->mergeRootExpr($this->rootExpr, $other->rootExpr); foreach ($this->sureTypes as $exprString => [$exprNode, $type]) { if (!isset($other->sureTypes[$exprString])) { @@ -512,6 +512,7 @@ public function unionWith(SpecifiedTypes $other): self ]; } + $result = new self($sureTypeUnion, $sureNotTypeUnion); $alternativeUnion = $this->alternativeTypes; foreach ($other->alternativeTypes as $exprString => [$exprNode, $otherTerms]) { if (!isset($alternativeUnion[$exprString])) { @@ -525,7 +526,6 @@ public function unionWith(SpecifiedTypes $other): self ]; } - $result = new self($sureTypeUnion, $sureNotTypeUnion); $result->alternativeTypes = $alternativeUnion; if ($this->overwrite || $other->overwrite) { $result = $result->setAlwaysOverwriteTypes(); @@ -546,83 +546,7 @@ public function unionWith(SpecifiedTypes $other): self return $result->setRootExpr($rootExpr); } - /** - * The n-ary both-sides-hold merge - the truthy narrowing of a flattened - * `&&` chain, the falsey narrowing of a flattened `||` chain. Same result - * as folding unionWith() over the list, but each expression's constraints - * are combined in one pass instead of being rebuilt per arm, which is what - * lets the flattened chain paths stay linear in the number of arms. - * - * @param list $typesList - */ - public static function unionAll(array $typesList): self - { - /** @var array}> $surePerExpr */ - $surePerExpr = []; - /** @var array}> $sureNotPerExpr */ - $sureNotPerExpr = []; - /** @var array}> $alternatives */ - $alternatives = []; - $overwrite = false; - $rootExpr = null; - $conditionalExpressionHolders = []; - $recipes = []; - $augments = []; - - foreach ($typesList as $types) { - foreach ($types->sureTypes as $exprString => [$exprNode, $type]) { - $surePerExpr[$exprString][0] = $exprNode; - $surePerExpr[$exprString][1][] = $type; - } - foreach ($types->sureNotTypes as $exprString => [$exprNode, $type]) { - $sureNotPerExpr[$exprString][0] = $exprNode; - $sureNotPerExpr[$exprString][1][] = $type; - } - foreach ($types->alternativeTypes as $exprString => [$exprNode, $terms]) { - if (!isset($alternatives[$exprString])) { - $alternatives[$exprString] = [$exprNode, $terms]; - continue; - } - - $alternatives[$exprString][1] = self::conjoinTerms($alternatives[$exprString][1], $terms); - } - - $overwrite = $overwrite || $types->overwrite; - $rootExpr = self::mergeRootExpr($rootExpr, $types->rootExpr); - - foreach ($types->newConditionalExpressionHolders as $exprString => $holders) { - if (!array_key_exists($exprString, $conditionalExpressionHolders)) { - $conditionalExpressionHolders[$exprString] = $holders; - } else { - $conditionalExpressionHolders[$exprString] = array_merge($conditionalExpressionHolders[$exprString], $holders); - } - } - $recipes = array_merge($recipes, $types->conditionalExpressionHolderRecipes); - $augments = array_merge($augments, $types->deferredAugments); - } - - $sureTypes = []; - foreach ($surePerExpr as $exprString => [$exprNode, $types]) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::intersect(...$types)]; - } - $sureNotTypes = []; - foreach ($sureNotPerExpr as $exprString => [$exprNode, $types]) { - $sureNotTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; - } - - $result = new self($sureTypes, $sureNotTypes); - $result->alternativeTypes = $alternatives; - if ($overwrite) { - $result = $result->setAlwaysOverwriteTypes(); - } - $result->newConditionalExpressionHolders = $conditionalExpressionHolders; - $result->conditionalExpressionHolderRecipes = $recipes; - $result->deferredAugments = $augments; - - return $result->setRootExpr($rootExpr); - } - - private static function mergeRootExpr(?Expr $rootExprA, ?Expr $rootExprB): ?Expr + private function mergeRootExpr(?Expr $rootExprA, ?Expr $rootExprB): ?Expr { if ($rootExprA === $rootExprB) { return $rootExprA; diff --git a/tests/PHPStan/Analyser/data/methodPhpDocs-recursive-trait-defined.php b/tests/PHPStan/Analyser/data/methodPhpDocs-recursive-trait-defined.php index 70c6ab079a6..e18357cc9cc 100644 --- a/tests/PHPStan/Analyser/data/methodPhpDocs-recursive-trait-defined.php +++ b/tests/PHPStan/Analyser/data/methodPhpDocs-recursive-trait-defined.php @@ -119,7 +119,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/data/methodPhpDocs-trait-defined.php b/tests/PHPStan/Analyser/data/methodPhpDocs-trait-defined.php index bb7b5c21a85..635d1097ab8 100644 --- a/tests/PHPStan/Analyser/data/methodPhpDocs-trait-defined.php +++ b/tests/PHPStan/Analyser/data/methodPhpDocs-trait-defined.php @@ -119,7 +119,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/closure-return-type.php b/tests/PHPStan/Analyser/nsrt/closure-return-type.php index 386fec990cc..df98e0b3993 100644 --- a/tests/PHPStan/Analyser/nsrt/closure-return-type.php +++ b/tests/PHPStan/Analyser/nsrt/closure-return-type.php @@ -12,12 +12,12 @@ public function doFoo(int $i): void $f = function () { }; - assertType('void', $f()); + assertType('null', $f()); $f = function () { return; }; - assertType('void', $f()); + assertType('null', $f()); $f = function () { return 1; diff --git a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phanPrefix.php b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phanPrefix.php index 6ded9fd90dd..dd768b725d7 100644 --- a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phanPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phanPrefix.php @@ -103,7 +103,7 @@ function doFooPhanPrefix( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phpstanPrefix.php b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phpstanPrefix.php index d3d7cae930f..ea3c056992e 100644 --- a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phpstanPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-phpstanPrefix.php @@ -103,7 +103,7 @@ function doFooPhpstanPrefix( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-psalmPrefix.php b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-psalmPrefix.php index 6f78457db28..8e23cd5a948 100644 --- a/tests/PHPStan/Analyser/nsrt/functionPhpDocs-psalmPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/functionPhpDocs-psalmPrefix.php @@ -103,7 +103,7 @@ function doFooPsalmPrefix( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/functionPhpDocs.php b/tests/PHPStan/Analyser/nsrt/functionPhpDocs.php index f38965983a4..96441e6873c 100644 --- a/tests/PHPStan/Analyser/nsrt/functionPhpDocs.php +++ b/tests/PHPStan/Analyser/nsrt/functionPhpDocs.php @@ -104,7 +104,7 @@ function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc-without-curly-braces.php b/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc-without-curly-braces.php index 2b482e70612..6f930012aff 100644 --- a/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc-without-curly-braces.php +++ b/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc-without-curly-braces.php @@ -85,7 +85,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc.php b/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc.php index 33a0558ceaa..1656c1e0982 100644 --- a/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc.php +++ b/tests/PHPStan/Analyser/nsrt/method-phpDocs-inheritdoc.php @@ -85,7 +85,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-implicitInheritance.php b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-implicitInheritance.php index e832ea66913..2f449bf3218 100644 --- a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-implicitInheritance.php +++ b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-implicitInheritance.php @@ -82,7 +82,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phanPrefix.php b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phanPrefix.php index 58de957d21b..018a22164b3 100644 --- a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phanPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phanPrefix.php @@ -128,7 +128,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phpstanPrefix.php b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phpstanPrefix.php index e7d824a5bbc..baff47d04a2 100644 --- a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phpstanPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-phpstanPrefix.php @@ -128,7 +128,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-psalmPrefix.php b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-psalmPrefix.php index c81f079f58d..c6a12e197c5 100644 --- a/tests/PHPStan/Analyser/nsrt/methodPhpDocs-psalmPrefix.php +++ b/tests/PHPStan/Analyser/nsrt/methodPhpDocs-psalmPrefix.php @@ -128,7 +128,7 @@ public function doFoo( assertType('resource', $resource); assertType('mixed', $yetAnotherAnotherMixedParameter); assertType('mixed', $yetAnotherAnotherAnotherMixedParameter); - assertType('void', $voidParameter); + assertType('null', $voidParameter); assertType('SomeNamespace\Consecteur', $useWithoutAlias); assertType('true', $true); assertType('false', $false); diff --git a/tests/PHPStan/Analyser/nsrt/missing-closure-native-return-typehint.php b/tests/PHPStan/Analyser/nsrt/missing-closure-native-return-typehint.php index d516f89f230..3e7ec048530 100644 --- a/tests/PHPStan/Analyser/nsrt/missing-closure-native-return-typehint.php +++ b/tests/PHPStan/Analyser/nsrt/missing-closure-native-return-typehint.php @@ -7,10 +7,10 @@ class Foo public function doFoo() { - \PHPStan\Testing\assertType('void', (function () { + \PHPStan\Testing\assertType('null', (function () { })()); - \PHPStan\Testing\assertType('void', (function () { + \PHPStan\Testing\assertType('null', (function () { return; })()); \PHPStan\Testing\assertType('Generator', (function (bool $bool) { diff --git a/tests/PHPStan/Analyser/nsrt/mixed-typehint.php b/tests/PHPStan/Analyser/nsrt/mixed-typehint.php index 5b3c17cbb1a..c7ec23d85ae 100644 --- a/tests/PHPStan/Analyser/nsrt/mixed-typehint.php +++ b/tests/PHPStan/Analyser/nsrt/mixed-typehint.php @@ -30,7 +30,7 @@ function (mixed $foo) { $f = function (): mixed { }; - assertType('void', $f()); + assertType('null', $f()); $f = function () use ($foo): mixed { return $foo; diff --git a/tests/PHPStan/Reflection/data/mixedType.php b/tests/PHPStan/Reflection/data/mixedType.php index d39d5a02aa1..037c65725c3 100644 --- a/tests/PHPStan/Reflection/data/mixedType.php +++ b/tests/PHPStan/Reflection/data/mixedType.php @@ -38,5 +38,5 @@ function (): void { assertType('mixed', $foo); }; - assertType('void', $f(1)); + assertType('null', $f(1)); }; diff --git a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php index 03448315326..b9334972896 100644 --- a/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallCallablesRuleTest.php @@ -346,7 +346,7 @@ public function testPipeOperator(): void 24, ], [ - 'Parameter #1 $i of callable \'CallCallablePipe…\' expects int, void given.', + 'Parameter #1 $i of callable \'CallCallablePipe…\' expects int, null given.', 26, ], [ diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index e47215d1f23..eb031ce1b48 100644 --- a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php @@ -38,11 +38,11 @@ public function testRule(): void { $this->analyse([__DIR__ . '/data/wrong-variable-name-var.php'], [ [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 11, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 14, ], [ @@ -86,7 +86,7 @@ public function testRule(): void 109, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 120, ], [ @@ -552,19 +552,19 @@ public function testAssignOperator(): void { $this->analyse([__DIR__ . '/data/wrong-variable-name-var-assign-op.php'], [ [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 11, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 14, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 20, ], [ - 'PHPDoc tag @var with type int is not subtype of native type void.', + 'PHPDoc tag @var with type int is not subtype of native type null.', 23, ], ]); From 6073452da74c8430c66efc83b5dc6bd3986e6c40 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:32 +0200 Subject: [PATCH 03/32] Store whole ExpressionResults instead of before-scopes ExpressionResultStorage now maps expressions to their full results, so a later consumer can read the type and narrowing of an already-processed node instead of re-walking it. duplicate() becomes O(1) through a read-only fallback chain, and mergeResults() unions only the storage's own entries (the trait-use path needs both). The new ExpressionResultStorageStack makes the storage of the analysis currently in progress reachable from any scope: both internal scope factories thread one shared stack instance into every MutatingScope they create, across the fiber/non-fiber boundary. The native ExpressionResultStorage twin mirrors the rework and the smoke test covers the fallback-chain semantics. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/DirectInternalScopeFactory.php | 30 +++--- src/Analyser/ExpressionResultStorage.php | 41 ++++---- src/Analyser/ExpressionResultStorageStack.php | 56 +++++++++++ src/Analyser/LazyInternalScopeFactory.php | 7 +- turbo-ext/src/ExpressionResultStorage.cpp | 94 +++++++++++++------ turbo-ext/tests/smoke.php | 80 +++++++++------- 6 files changed, 212 insertions(+), 96 deletions(-) create mode 100644 src/Analyser/ExpressionResultStorageStack.php diff --git a/src/Analyser/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index 9cb78a28e8b..4b7c663c2d4 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -20,6 +20,8 @@ final class DirectInternalScopeFactory implements InternalScopeFactory { + private ExpressionResultStorageStack $expressionResultStorageStack; + /** * @param int|array{min: int, max: int}|null $configPhpVersion * @param callable(Node $node, Scope $scope): void|null $nodeCallback @@ -40,8 +42,10 @@ public function __construct( private $nodeCallback, private ConstantResolver $constantResolver, private bool $fiber = false, + ?ExpressionResultStorageStack $expressionResultStorageStack = null, ) { + $this->expressionResultStorageStack = $expressionResultStorageStack ?? new ExpressionResultStorageStack(); } public function create( @@ -79,6 +83,7 @@ public function create( $this->propertyReflectionFinder, $this->parser, $this->constantResolver, + $this->expressionResultStorageStack, $context, $this->phpVersion, $this->attributeReflectionFactory, @@ -104,25 +109,15 @@ public function create( public function toFiberFactory(): InternalScopeFactory { - return new self( - $this->container, - $this->reflectionProvider, - $this->initializerExprTypeResolver, - $this->expressionTypeResolverExtensions, - $this->exprPrinter, - $this->typeSpecifier, - $this->propertyReflectionFinder, - $this->parser, - $this->phpVersion, - $this->attributeReflectionFactory, - $this->configPhpVersion, - $this->nodeCallback, - $this->constantResolver, - true, - ); + return $this->withFlavor(true); } public function toMutatingFactory(): InternalScopeFactory + { + return $this->withFlavor(false); + } + + private function withFlavor(bool $fiber): self { return new self( $this->container, @@ -138,7 +133,8 @@ public function toMutatingFactory(): InternalScopeFactory $this->configPhpVersion, $this->nodeCallback, $this->constantResolver, - false, + $fiber, + $this->expressionResultStorageStack, ); } diff --git a/src/Analyser/ExpressionResultStorage.php b/src/Analyser/ExpressionResultStorage.php index e09bf0012c6..023ac168e34 100644 --- a/src/Analyser/ExpressionResultStorage.php +++ b/src/Analyser/ExpressionResultStorage.php @@ -8,26 +8,24 @@ use PHPStan\Analyser\Fiber\ExpressionResultRequest; use PHPStan\Analyser\Fiber\ParkFiberRequest; use PHPStan\Turbo\ShadowedByTurboExtension; -use function spl_object_id; +use SplObjectStorage; #[ShadowedByTurboExtension(turboClass: 'PHPStanTurbo\ExpressionResultStorage', implementation: __DIR__ . '/../../turbo-ext/src/ExpressionResultStorage.cpp')] final class ExpressionResultStorage { + /** @var SplObjectStorage */ + private SplObjectStorage $exprResults; + /** - * Keeps every stored Expr alive so its spl_object_id() cannot be reused - * by another node while $scopesById still maps it. - * - * @var array + * Read-only fallback - writes never reach it. Makes duplicate() O(1) + * instead of copying all stored results. */ - private array $exprsById = []; - - /** @var array */ - private array $scopesById = []; + private ?self $fallback = null; /** * Keyed by spl_object_id() of the requested Expr, so resolving a stored - * before-scope touches only the fibers waiting for that expression. + * expression result touches only the fibers waiting for that expression. * The request object keeps the Expr alive, so its id cannot be reused * while the entry exists. * @@ -38,24 +36,31 @@ final class ExpressionResultStorage /** @var list> */ public array $parkedFibers = []; + public function __construct() + { + $this->exprResults = new SplObjectStorage(); + } + public function duplicate(): self { $new = new self(); - $new->exprsById = $this->exprsById; - $new->scopesById = $this->scopesById; + $new->fallback = $this; return $new; } - public function storeBeforeScope(Expr $expr, Scope $scope): void + public function mergeResults(self $other): void + { + $this->exprResults->addAll($other->exprResults); + } + + public function storeExpressionResult(Expr $expr, ExpressionResult $expressionResult): void { - $id = spl_object_id($expr); - $this->exprsById[$id] = $expr; - $this->scopesById[$id] = $scope; + $this->exprResults[$expr] = $expressionResult; } - public function findBeforeScope(Expr $expr): ?Scope + public function findExpressionResult(Expr $expr): ?ExpressionResult { - return $this->scopesById[spl_object_id($expr)] ?? null; + return $this->exprResults[$expr] ?? ($this->fallback !== null ? $this->fallback->findExpressionResult($expr) : null); } } diff --git a/src/Analyser/ExpressionResultStorageStack.php b/src/Analyser/ExpressionResultStorageStack.php new file mode 100644 index 00000000000..7c1ac9d4ab2 --- /dev/null +++ b/src/Analyser/ExpressionResultStorageStack.php @@ -0,0 +1,56 @@ + results -> scopes -> storage) + * that never gets collected because the cycle collector is disabled + * in bin/phpstan. + * + * NodeScopeResolver pushes a storage for the duration of an analysis (file, + * statement list, trait pass, on-demand expression) through + * MutatingScope::pushExpressionResultStorage() and must always pop it + * in a finally block. Old-world type questions about an expression are answered + * from the current storage (see MutatingScope::resolveTypeOfNewWorldHandlerNode()). + * A scope used outside any running analysis simply misses here and resolves + * on demand with a throwaway storage. + */ +final class ExpressionResultStorageStack +{ + + /** @var list */ + private array $stack = []; + + public function push(ExpressionResultStorage $storage): void + { + $this->stack[] = $storage; + } + + public function pop(): void + { + if (count($this->stack) === 0) { + throw new ShouldNotHappenException('Unbalanced ExpressionResultStorageStack pop.'); + } + + array_pop($this->stack); + } + + public function getCurrent(): ?ExpressionResultStorage + { + if (count($this->stack) === 0) { + return null; + } + + return $this->stack[count($this->stack) - 1]; + } + +} diff --git a/src/Analyser/LazyInternalScopeFactory.php b/src/Analyser/LazyInternalScopeFactory.php index 5e2a5919bfb..a158407bdbb 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -43,6 +43,8 @@ final class LazyInternalScopeFactory implements InternalScopeFactory private ?ConstantResolver $constantResolver = null; + private ExpressionResultStorageStack $expressionResultStorageStack; + private ?PhpVersion $phpVersionType = null; private ?AttributeReflectionFactory $attributeReflectionFactory = null; @@ -59,10 +61,12 @@ public function __construct( private Container $container, private $nodeCallback, private bool $fiber = false, + ?ExpressionResultStorageStack $expressionResultStorageStack = null, ) { $this->phpVersion = $this->container->getParameter('phpVersion'); $this->currentSimpleVersionParser = $this->container->getService('currentPhpVersionSimpleParser'); + $this->expressionResultStorageStack = $expressionResultStorageStack ?? new ExpressionResultStorageStack(); } public function create( @@ -112,6 +116,7 @@ public function create( $this->propertyReflectionFinder, $this->currentSimpleVersionParser, $this->constantResolver, + $this->expressionResultStorageStack, $context, $this->phpVersionType, $this->attributeReflectionFactory, @@ -170,7 +175,7 @@ private function twin(): self } } - $this->twin = new self($this->container, $this->nodeCallback, !$this->fiber); + $this->twin = new self($this->container, $this->nodeCallback, !$this->fiber, $this->expressionResultStorageStack); $this->twin->origin = WeakReference::create($this); return $this->twin; diff --git a/turbo-ext/src/ExpressionResultStorage.cpp b/turbo-ext/src/ExpressionResultStorage.cpp index f50eb7bf81b..25bdf3bd65e 100644 --- a/turbo-ext/src/ExpressionResultStorage.cpp +++ b/turbo-ext/src/ExpressionResultStorage.cpp @@ -6,11 +6,14 @@ * instances of the object's own class (the stub), so userland type hints * keep working without a configured Impl entry. * - * The before-scope table is two id-keyed arrays in private property slots, - * exactly like the PHP twin: exprsById pins each stored Expr so its object - * handle cannot be reused while scopesById still maps it. duplicate() copies - * the two array zvals by refcount (copy-on-write) — the eager per-entry copy - * of the twin's former SplObjectStorage is what made this worth porting. + * The result table is two id-keyed arrays in private property slots: + * exprsById pins each stored Expr so its object handle cannot be reused + * while resultsById still maps it — the PHP twin's SplObjectStorage pins its + * keys the same way. duplicate() copies nothing: the new storage carries the + * source as its read-only fallback (writes never reach it), mirroring the + * twin's O(1) duplicate(); findExpressionResult() walks the fallback chain + * on a miss. mergeResults() unions the other storage's own entries (not its + * fallback chain) into this one, like the twin's SplObjectStorage::addAll(). * pendingFibers/parkedFibers are ordinary public properties read and written * by FiberNodeScopeResolver in PHP; the native code never touches them. */ @@ -19,12 +22,13 @@ #include "zv.h" #define PT_ERS_PROP_EXPRS 0 -#define PT_ERS_PROP_SCOPES 1 +#define PT_ERS_PROP_RESULTS 1 +#define PT_ERS_PROP_FALLBACK 2 namespace phpstanturbo { /* Mirrors PHPStan\Analyser\ExpressionResultStorage. State lives in the PHP - * object's exprsById/scopesById properties. */ + * object's exprsById/resultsById/fallback properties. */ class ExpressionResultStorage { public: @@ -36,28 +40,49 @@ class ExpressionResultStorage if (UNEXPECTED(object_init_ex(&newObj, Z_OBJCE_P(self)) != SUCCESS)) { return zv::Val(); } - zv::ObjRef src(self); - zv::ObjRef dst(&newObj); - dst.propAtWrite(PT_ERS_PROP_EXPRS, zv::Val::copyOf(src.propAt(PT_ERS_PROP_EXPRS))); - dst.propAtWrite(PT_ERS_PROP_SCOPES, zv::Val::copyOf(src.propAt(PT_ERS_PROP_SCOPES))); + zv::ObjRef(&newObj).propAtWrite(PT_ERS_PROP_FALLBACK, zv::Val::copyOf(zv::Ref(self))); return zv::Val::adopt(newObj); } - void storeBeforeScope(zval *expr, zval *scope) + void mergeResults(zval *other) + { + zv::ObjRef src(other); + zv::ObjRef dst(self); + zv::ArrRef dstExprs(dst.propAt(PT_ERS_PROP_EXPRS).raw()); + zv::ArrRef dstResults(dst.propAt(PT_ERS_PROP_RESULTS).raw()); + for (auto entry : zv::ArrRef(src.propAt(PT_ERS_PROP_EXPRS).raw())) { + dstExprs.setIndex(entry.indexKey(), entry.value()); + } + for (auto entry : zv::ArrRef(src.propAt(PT_ERS_PROP_RESULTS).raw())) { + dstResults.setIndex(entry.indexKey(), entry.value()); + } + } + + void storeExpressionResult(zval *expr, zval *expressionResult) { zend_ulong id = Z_OBJ_HANDLE_P(expr); zv::ObjRef obj(self); zv::ArrRef(obj.propAt(PT_ERS_PROP_EXPRS).raw()).setIndex(id, zv::Ref(expr)); - zv::ArrRef(obj.propAt(PT_ERS_PROP_SCOPES).raw()).setIndex(id, zv::Ref(scope)); + zv::ArrRef(obj.propAt(PT_ERS_PROP_RESULTS).raw()).setIndex(id, zv::Ref(expressionResult)); } - zv::Val findBeforeScope(zval *expr) const + zv::Val findExpressionResult(zval *expr) const { - zv::Ref found = zv::ArrRef(zv::ObjRef(self).propAt(PT_ERS_PROP_SCOPES).raw()).findIndex(Z_OBJ_HANDLE_P(expr)); - if (found.raw() == NULL) { - return zv::Val::null(); + zend_ulong id = Z_OBJ_HANDLE_P(expr); + zval *cur = self; + for (;;) { + zv::ObjRef obj(cur); + zv::Ref found = zv::ArrRef(obj.propAt(PT_ERS_PROP_RESULTS).raw()).findIndex(id); + if (found.raw() != NULL) { + return zv::Val::copyOf(found); + } + /* the twin recurses into ?self $fallback; iterate the chain */ + zval *fallback = obj.propAt(PT_ERS_PROP_FALLBACK).raw(); + if (Z_TYPE_P(fallback) != IS_OBJECT) { + return zv::Val::null(); + } + cur = fallback; } - return zv::Val::copyOf(found); } private: @@ -75,13 +100,20 @@ using phpstanturbo::ExpressionResultStorage; void pt_register_expression_result_storage() { reg::Class cls("PHPStanTurbo\\ExpressionResultStorage"); - /* not final: a PHP stub subclass extends this class; exprsById/scopesById - * must stay in this order (OBJ_PROP_NUM slots) */ + /* not final: a PHP stub subclass extends this class; exprsById/resultsById/ + * fallback must stay in this order (OBJ_PROP_NUM slots) */ cls.privateArrayProperty("exprsById"); - cls.privateArrayProperty("scopesById"); + cls.privateArrayProperty("resultsById"); + cls.privateNullProperty("fallback"); cls.publicArrayProperty("pendingFibers"); cls.publicArrayProperty("parkedFibers"); + /* the twin's constructor only initialized its SplObjectStorage; the + * native property defaults already cover that */ + cls.method("__construct", reg::Public, 0, {}, [](INTERNAL_FUNCTION_PARAMETERS) { + ZEND_PARSE_PARAMETERS_NONE(); + }); + cls.method("duplicate", reg::Public, 0, {}, [](INTERNAL_FUNCTION_PARAMETERS) { ZEND_PARSE_PARAMETERS_NONE(); zv::Val result = ExpressionResultStorage(ZEND_THIS).duplicate(); @@ -91,21 +123,29 @@ void pt_register_expression_result_storage() result.intoReturnValue(return_value); }); - cls.method("storeBeforeScope", reg::Public, 2, { reg::any("expr"), reg::any("scope") }, [](INTERNAL_FUNCTION_PARAMETERS) { - zval *expr, *scope; + cls.method("mergeResults", reg::Public, 1, { reg::any("other") }, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *other; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_OBJECT(other) + ZEND_PARSE_PARAMETERS_END(); + ExpressionResultStorage(ZEND_THIS).mergeResults(other); + }); + + cls.method("storeExpressionResult", reg::Public, 2, { reg::any("expr"), reg::any("expressionResult") }, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *expr, *expressionResult; ZEND_PARSE_PARAMETERS_START(2, 2) Z_PARAM_OBJECT(expr) - Z_PARAM_OBJECT(scope) + Z_PARAM_OBJECT(expressionResult) ZEND_PARSE_PARAMETERS_END(); - ExpressionResultStorage(ZEND_THIS).storeBeforeScope(expr, scope); + ExpressionResultStorage(ZEND_THIS).storeExpressionResult(expr, expressionResult); }); - cls.method("findBeforeScope", reg::Public, 1, { reg::any("expr") }, [](INTERNAL_FUNCTION_PARAMETERS) { + cls.method("findExpressionResult", reg::Public, 1, { reg::any("expr") }, [](INTERNAL_FUNCTION_PARAMETERS) { zval *expr; ZEND_PARSE_PARAMETERS_START(1, 1) Z_PARAM_OBJECT(expr) ZEND_PARSE_PARAMETERS_END(); - ExpressionResultStorage(ZEND_THIS).findBeforeScope(expr).intoReturnValue(return_value); + ExpressionResultStorage(ZEND_THIS).findExpressionResult(expr).intoReturnValue(return_value); }); cls.register_(); diff --git a/turbo-ext/tests/smoke.php b/turbo-ext/tests/smoke.php index a5b825ac314..786fb35b01d 100644 --- a/turbo-ext/tests/smoke.php +++ b/turbo-ext/tests/smoke.php @@ -226,6 +226,21 @@ function check(bool $cond, string $msg): void check(\PHPStanTurbo\ExpressionTypeHolder::createMaybe($expr1, $int)->getCertainty()->maybe(), 'ETH createMaybe'); check(\PHPStanTurbo\ExpressionTypeHolder::createYes($expr1, $int)->getType() === $int, 'ETH createYes type identity'); +// ---- ScopeOps::mergeVariableHolders differingKeys ---- +$sharedP = $pH($expr1, $int, $pYes); +$sharedN = $nH($expr1, $int, $nYes); +$mergePOurs = ['$shared' => $sharedP, '$a' => $pH($expr1, $int, $pYes), '$b' => $pH($expr2, $string, $pYes)]; +$mergePTheirs = ['$shared' => $sharedP, '$b' => $pH($expr2, $string, $pMaybe), '$c' => $pH($expr2, $int, $pYes)]; +$mergeNOurs = ['$shared' => $sharedN, '$a' => $nH($expr1, $int, $nYes), '$b' => $nH($expr2, $string, $nYes)]; +$mergeNTheirs = ['$shared' => $sharedN, '$b' => $nH($expr2, $string, $nMaybe), '$c' => $nH($expr2, $int, $nYes)]; +$pDiffering = []; +$pMerged = \PHPStan\Analyser\ScopeOps::mergeVariableHolders($mergePOurs, $mergePTheirs, $pDiffering); +$nDiffering = []; +$nMerged = \PHPStanTurbo\ScopeOps::mergeVariableHolders($mergeNOurs, $mergeNTheirs, $nDiffering); +check($pDiffering === $nDiffering, 'ScopeOps mergeVariableHolders differingKeys parity: ' . json_encode($pDiffering) . ' vs ' . json_encode($nDiffering)); +check(array_keys($pMerged) === array_keys($nMerged), 'ScopeOps mergeVariableHolders merged keys parity'); +check(array_keys(\PHPStanTurbo\ScopeOps::mergeVariableHolders($mergeNOurs, $mergeNTheirs)) === array_keys($nMerged), 'ScopeOps mergeVariableHolders without differingKeys'); + // ---- ConditionalExpressionHolder ---- $covered[\PHPStan\Analyser\ConditionalExpressionHolder::class] = true; $pCEH = new \PHPStan\Analyser\ConditionalExpressionHolder( @@ -298,9 +313,9 @@ function check(bool $cond, string $msg): void // ---- ExpressionResultStorage ---- $covered[\PHPStan\Analyser\ExpressionResultStorage::class] = true; -$makeScope = static function () { +$makeResult = static function () { static $reflection = null; - $reflection ??= new ReflectionClass(\PHPStan\Analyser\MutatingScope::class); + $reflection ??= new ReflectionClass(\PHPStan\Analyser\ExpressionResult::class); return $reflection->newInstanceWithoutConstructor(); }; @@ -308,24 +323,39 @@ function check(bool $cond, string $msg): void $storage = new $storageClass(); $exprA = new \PhpParser\Node\Expr\Variable('a'); $exprB = new \PhpParser\Node\Expr\Variable('b'); - $scopeA = $makeScope(); - $scopeB = $makeScope(); - - check($storage->findBeforeScope($exprA) === null, "ERS $label: find on empty storage is null"); - $storage->storeBeforeScope($exprA, $scopeA); - check($storage->findBeforeScope($exprA) === $scopeA, "ERS $label: find returns the stored scope"); - check($storage->findBeforeScope($exprB) === null, "ERS $label: unknown expr is null"); - $storage->storeBeforeScope($exprA, $scopeB); - check($storage->findBeforeScope($exprA) === $scopeB, "ERS $label: overwrite for the same expr"); + $exprC = new \PhpParser\Node\Expr\Variable('c'); + $resultA = $makeResult(); + $resultB = $makeResult(); + $resultC = $makeResult(); + + check($storage->findExpressionResult($exprA) === null, "ERS $label: find on empty storage is null"); + $storage->storeExpressionResult($exprA, $resultA); + check($storage->findExpressionResult($exprA) === $resultA, "ERS $label: find returns the stored result"); + check($storage->findExpressionResult($exprB) === null, "ERS $label: unknown expr is null"); + $storage->storeExpressionResult($exprA, $resultB); + check($storage->findExpressionResult($exprA) === $resultB, "ERS $label: overwrite for the same expr"); $duplicate = $storage->duplicate(); check(get_class($duplicate) === $storageClass, "ERS $label: duplicate creates the same class"); - check($duplicate->findBeforeScope($exprA) === $scopeB, "ERS $label: duplicate carries stored entries"); - $duplicate->storeBeforeScope($exprB, $scopeA); - check($duplicate->findBeforeScope($exprB) === $scopeA, "ERS $label: store on the duplicate"); - check($storage->findBeforeScope($exprB) === null, "ERS $label: duplicate stores do not leak back"); - $storage->storeBeforeScope($exprB, $scopeB); - check($duplicate->findBeforeScope($exprB) === $scopeA, "ERS $label: original stores do not leak into the duplicate"); + check($duplicate->findExpressionResult($exprA) === $resultB, "ERS $label: duplicate reads through the fallback"); + $duplicate->storeExpressionResult($exprB, $resultA); + check($duplicate->findExpressionResult($exprB) === $resultA, "ERS $label: store on the duplicate"); + check($storage->findExpressionResult($exprB) === null, "ERS $label: duplicate stores do not leak back"); + $duplicate->storeExpressionResult($exprA, $resultC); + check($duplicate->findExpressionResult($exprA) === $resultC, "ERS $label: duplicate store shadows the fallback"); + check($storage->findExpressionResult($exprA) === $resultB, "ERS $label: shadowing store does not leak back"); + + $grandchild = $duplicate->duplicate(); + check($grandchild->findExpressionResult($exprB) === $resultA, "ERS $label: find walks the whole fallback chain"); + + $other = new $storageClass(); + $other->storeExpressionResult($exprC, $resultC); + $otherChild = $other->duplicate(); + $otherChild->storeExpressionResult($exprB, $resultB); + $storage->mergeResults($otherChild); + check($storage->findExpressionResult($exprB) === $resultB, "ERS $label: mergeResults carries the other's own entries"); + check($storage->findExpressionResult($exprC) === null, "ERS $label: mergeResults ignores the other's fallback chain"); + check($storage->findExpressionResult($exprA) === $resultB, "ERS $label: mergeResults keeps existing entries"); check($duplicate->pendingFibers === [] && $duplicate->parkedFibers === [], "ERS $label: duplicate starts with empty fiber arrays"); $storage->pendingFibers[] = ['marker' => 1]; @@ -337,22 +367,6 @@ function check(bool $cond, string $msg): void check($storage->pendingFibers === [], "ERS $label: fiber array entries can be unset"); } -// ---- ScopeOps::mergeVariableHolders differingKeys ---- -$sharedP = $pH($expr1, $int, $pYes); -$sharedN = $nH($expr1, $int, $nYes); -$mergePOurs = ['$shared' => $sharedP, '$a' => $pH($expr1, $int, $pYes), '$b' => $pH($expr2, $string, $pYes)]; -$mergePTheirs = ['$shared' => $sharedP, '$b' => $pH($expr2, $string, $pMaybe), '$c' => $pH($expr2, $int, $pYes)]; -$mergeNOurs = ['$shared' => $sharedN, '$a' => $nH($expr1, $int, $nYes), '$b' => $nH($expr2, $string, $nYes)]; -$mergeNTheirs = ['$shared' => $sharedN, '$b' => $nH($expr2, $string, $nMaybe), '$c' => $nH($expr2, $int, $nYes)]; -$pDiffering = []; -$pMerged = \PHPStan\Analyser\ScopeOps::mergeVariableHolders($mergePOurs, $mergePTheirs, $pDiffering); -$nDiffering = []; -$nMerged = \PHPStanTurbo\ScopeOps::mergeVariableHolders($mergeNOurs, $mergeNTheirs, $nDiffering); -check($pDiffering === $nDiffering, 'ScopeOps mergeVariableHolders differingKeys parity: ' . json_encode($pDiffering) . ' vs ' . json_encode($nDiffering)); -check(array_keys($pMerged) === array_keys($nMerged), 'ScopeOps mergeVariableHolders merged keys parity'); -check(array_keys(\PHPStanTurbo\ScopeOps::mergeVariableHolders($mergeNOurs, $mergeNTheirs)) === array_keys($nMerged), 'ScopeOps mergeVariableHolders without differingKeys'); - - // ---- NodeScanner ---- $covered[\PHPStan\Node\NodeScanner::class] = true; $smokeParserFactory = new \PhpParser\ParserFactory(); From 0e0943af6b952c4da9e66d05210331f55424a9bd Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:32 +0200 Subject: [PATCH 04/32] Compose default narrowing from walk results in DefaultNarrowingHelper DefaultNarrowingHelper is the new-world counterpart of TypeSpecifier's default truthy/falsey handling, create()/createForExpr() and the assert/conditional-return specification: narrowing is composed from the already-walked subject's ExpressionResult (impure-call gate, plain-twin fan for chains containing nullsafe operators, isset chain entries) instead of re-probing the scope. CountNarrowingHelper receives the count()/sizeof() size specification that lived in TypeSpecifier. The helpers get their consumers as the handlers' resolveType() and specifyTypes() implementations move into result callbacks over the following commits. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- .../Helper/CountNarrowingHelper.php | 183 ++++ .../Helper/DefaultNarrowingHelper.php | 983 ++++++++++++++++++ 2 files changed, 1166 insertions(+) create mode 100644 src/Analyser/ExprHandler/Helper/CountNarrowingHelper.php create mode 100644 src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php diff --git a/src/Analyser/ExprHandler/Helper/CountNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/CountNarrowingHelper.php new file mode 100644 index 00000000000..650f1ceb07d --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/CountNarrowingHelper.php @@ -0,0 +1,183 @@ +getArgs()) === 1) { + return TrinaryLogic::createYes(); + } + + $modeArg = $countFuncCall->getArgs()[1]->value; + $storage = $scope->getCurrentExpressionResultStorage(); + $modeResult = $storage !== null ? $storage->findExpressionResult($modeArg) : null; + $mode = $modeResult !== null + ? $modeResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) + : $scope->getType($modeArg); + + return (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($typeToCount->getIterableValueType()->isArray()->negate()); + } + + public function specifyCountSize( + FuncCall $countFuncCall, + Type $type, + Type $sizeType, + TypeSpecifierContext $context, + MutatingScope $scope, + Expr $rootExpr, + ): ?SpecifiedTypes + { + $isConstantArray = $type->isConstantArray(); + $isList = $type->isList(); + $oneOrMore = IntegerRangeType::fromInterval(1, null); + if ( + !$this->isNormalCountCall($countFuncCall, $type, $scope)->yes() + || (!$isConstantArray->yes() && !$isList->yes()) + || !$oneOrMore->isSuperTypeOf($sizeType)->yes() + || $sizeType->isSuperTypeOf($type->getArraySize())->yes() + ) { + return null; + } + + if ($context->falsey() && $isConstantArray->yes()) { + $remainingSize = TypeCombinator::remove($type->getArraySize(), $sizeType); + if (!$remainingSize instanceof NeverType) { + $negatedContext = $context->false() + ? TypeSpecifierContext::createTrue() + : TypeSpecifierContext::createTruthy(); + $result = $this->specifyCountSize( + $countFuncCall, + $type, + $remainingSize, + $negatedContext, + $scope, + $rootExpr, + ); + if ($result !== null) { + return $result; + } + } + + // Fallback: directly filter constant arrays by their exact sizes. + // This avoids using TypeCombinator::remove() with falsey context, + // which can incorrectly remove arrays whose count doesn't match + // but whose shape is a subtype of the matched array. + $keptTypes = []; + foreach ($type->getConstantArrays() as $arrayType) { + if ($sizeType->isSuperTypeOf($arrayType->getArraySize())->yes()) { + continue; + } + + $keptTypes[] = $arrayType; + } + if ($keptTypes !== []) { + return $this->defaultNarrowingHelper->createForSubject( + $countFuncCall->getArgs()[0]->value, + TypeCombinator::union(...$keptTypes), + $context->negate(), + $scope, + )->setRootExpr($rootExpr); + } + } + + $resultTypes = []; + foreach ($type->getArrays() as $arrayType) { + $isSizeSuperTypeOfArraySize = $sizeType->isSuperTypeOf($arrayType->getArraySize()); + if ($isSizeSuperTypeOfArraySize->no()) { + continue; + } + + if ($context->falsey() && $isSizeSuperTypeOfArraySize->maybe()) { + continue; + } + + $resultTypes[] = $isList->yes() + ? $arrayType->truncateListToSize($sizeType) + : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + + if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { + $hasOptionalKeysOrUnsealed = false; + foreach ($type->getConstantArrays() as $arrayType) { + if ($arrayType->getOptionalKeys() !== [] || $arrayType->isUnsealed()->yes()) { + // Unsealed CATs can't be narrowed via the + // `HasOffsetValueType`-only shortcut below — the + // intersection of an unsealed shape with a single-slot + // constraint produces `NeverType`. Fall through to + // the full builder-based narrowing, which carries the + // unsealed slot via the loop above. + $hasOptionalKeysOrUnsealed = true; + break; + } + } + + if (!$hasOptionalKeysOrUnsealed) { + $argExpr = $countFuncCall->getArgs()[0]->value; + $argExprString = $this->exprPrinter->printExpr($argExpr); + + $sizeMin = null; + $sizeMax = null; + if ($sizeType instanceof ConstantIntegerType) { + $sizeMin = $sizeType->getValue(); + $sizeMax = $sizeType->getValue(); + } elseif ($sizeType instanceof IntegerRangeType) { + $sizeMin = $sizeType->getMin(); + $sizeMax = $sizeType->getMax(); + } + + $sureTypes = []; + $sureNotTypes = []; + + if ($sizeMin !== null && $sizeMin >= 1) { + $sureTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMin - 1), new MixedType())]; + } + if ($sizeMax !== null) { + $sureNotTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMax), new MixedType())]; + } + + if ($sureTypes !== [] || $sureNotTypes !== []) { + return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($rootExpr); + } + } + } + + return $this->defaultNarrowingHelper->createForSubject($countFuncCall->getArgs()[0]->value, TypeCombinator::union(...$resultTypes), $context, $scope)->setRootExpr($rootExpr); + } + +} diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php new file mode 100644 index 00000000000..2e6444e7080 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -0,0 +1,983 @@ +` - they + * emit the plain-chain variant alongside their own key once, and every parent + * simply composes their results. No recursive chain-walking, no type ask. + */ +#[AutowiredService] +final class DefaultNarrowingHelper +{ + + public function __construct( + private ExprPrinter $exprPrinter, + #[AutowiredParameter] + private bool $rememberPossiblyImpureFunctionValues, + private ReflectionProvider $reflectionProvider, + ) + { + } + + /** + * Narrows an arbitrary (often synthetic) node in the given boolean context by + * processing it on demand and asking its result, the inside-out replacement + * for TypeSpecifier::specifyTypesInCondition() on the handler path. A node not + * stored is processed on demand; a node whose handler wired no specifyTypesCallback + * (or no handler) yields the default truthy/falsey narrowing. + */ + public function specifyTypesForNode(Scope $scope, Expr $node, TypeSpecifierContext $context): SpecifiedTypes + { + if ($node instanceof Expr\CallLike && $node->isFirstClassCallable()) { + return (new SpecifiedTypes([], []))->setRootExpr($node); + } + + return $scope->toMutatingScope()->specifyTypesOfNewWorldHandlerNode($node, $context); + } + + public function specifyDefaultTypes(Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + { + if ($context->null()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + if (!$context->truthy()) { + $removedType = StaticTypeFactory::truthy(); + } elseif (!$context->falsey()) { + $removedType = StaticTypeFactory::falsey(); + } else { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return (new SpecifiedTypes(sureNotTypes: [ + $this->exprPrinter->printExpr($expr) => [$expr, $removedType], + ]))->setRootExpr($expr); + } + + /** + * Converts sure-not entries to sure form against the given evaluation + * scope (position-fixed, captured at compose time) - for the decided + * comparison paths whose consumers need a concrete sure type. This is NOT + * the deleted SpecifiedTypes::normalize(): the scope here is the + * narrowing's own evaluation position, never the application point. + */ + public function toSureTypes(SpecifiedTypes $types, MutatingScope $evaluationScope): SpecifiedTypes + { + $sureTypes = $types->getSureTypes(); + + foreach ($types->getSureNotTypes() as $exprString => [$exprNode, $sureNotType]) { + if (!isset($sureTypes[$exprString])) { + $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($evaluationScope->getStateType($exprNode), $sureNotType)]; + continue; + } + + $sureTypes[$exprString][1] = TypeCombinator::remove($sureTypes[$exprString][1], $sureNotType); + } + + $result = new SpecifiedTypes($sureTypes, []); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + + return $result->setRootExpr($types->getRootExpr()); + } + + /** + * The new-world counterpart of TypeSpecifier::create() for a subject the + * calling handler has already processed. The subject's own result says how + * a type constraint on it translates into entries (an assignment fans out + * to the assigned variable, a coalesce delegates to its left side); without + * a createTypesCallback the entries are composed here from the result's own + * facts: a call whose execution is (possibly) impure gets none, a chain + * containing a nullsafe additionally narrows its short-circuited plain twin. + * TypeSpecifier::create()/createForExpr() are never reached - their + * old-world machinery re-derives from the scope what the result already + * carries. + */ + public function createSubjectTypes(MutatingScope $s, Expr $subject, ?ExpressionResult $subjectResult, Type $type, TypeSpecifierContext $context): SpecifiedTypes + { + if ($subjectResult !== null) { + $createdTypes = $subjectResult->getCreatedTypesForScope($s, $type, $context); + if ($createdTypes !== null) { + return $createdTypes; + } + } + + return $this->createSubjectTypesFromResultState($s, $subject, $subjectResult, $type, $context); + } + + /** + * The fallback entry building - createSubjectTypes() without consulting the + * result's createTypesCallback. A handler's OWN createTypesCallback delegates + * here with its stored result so the impure gate and the nullsafe-chain fan + * still read the result state without re-entering itself. + */ + public function createSubjectTypesFromResultState(MutatingScope $s, Expr $subject, ?ExpressionResult $subjectResult, Type $type, TypeSpecifierContext $context): SpecifiedTypes + { + if ($subject instanceof Expr\Instanceof_ || $subject instanceof Expr\List_) { + return new SpecifiedTypes([], []); + } + + $exprToSpecify = $subject; + if ($subjectResult !== null) { + // a call whose own execution is (possibly) impure must not get a + // remembered type - the gate reads the result's own impure point + // instead of re-asking reflection like the old create() did + if ( + $subject instanceof Expr\FuncCall + || $subject instanceof Expr\MethodCall + || $subject instanceof Expr\StaticCall + || $subject instanceof Expr\NullsafeMethodCall + ) { + foreach ($subjectResult->getImpurePoints() as $impurePoint) { + if ($impurePoint->getNode() !== $subject) { + continue; + } + if ($impurePoint->isCertain() || !$this->rememberPossiblyImpureFunctionValues) { + // the call's value is not remembered, but a nullsafe + // receiver chain still narrows not-null: the chain must + // have evaluated for the (impure) call to produce any + // non-null value at all (mirrors the old createForExpr() + // returning createNullsafeTypes() from its impure branch) + if ($subjectResult->containsNullsafe() && $this->nullsafeShortCircuitRuledOut($s, $subjectResult, $type, $context)) { + return $this->createFirstNullsafeReceiverTypes($s, $subject) ?? new SpecifiedTypes([], []); + } + + return new SpecifiedTypes([], []); + } + + break; + } + } + + // a chain containing a nullsafe narrows its short-circuited plain + // twin too, when the constraint (or the subject's own type) rules + // the short-circuit null out - the containsNullsafe flag and the + // memoized result type replace the old scope-type probe + if ($subjectResult->containsNullsafe()) { + $nullRuledOut = $this->nullsafeShortCircuitRuledOut($s, $subjectResult, $type, $context); + + if ($nullRuledOut) { + $exprToSpecify = NullsafeOperatorHelper::getNullsafeShortcircuitedExpr($subject); + // a plain fetch/call wrapped AROUND a nullsafe chain has no + // createTypesCallback of its own - fan "the chain did not + // short-circuit" through the first nullsafe below it, like + // the old create()'s createNullsafeTypes() union + $nullsafeFanTypes = $this->createFirstNullsafeReceiverTypes($s, $subject); + } + } + } + + $sureTypes = []; + $sureNotTypes = []; + if ($context->false()) { + $sureNotTypes[$this->exprPrinter->printExpr($exprToSpecify)] = [$exprToSpecify, $type]; + if ($exprToSpecify !== $subject) { + $sureNotTypes[$this->exprPrinter->printExpr($subject)] = [$subject, $type]; + } + } elseif ($context->true()) { + $sureTypes[$this->exprPrinter->printExpr($exprToSpecify)] = [$exprToSpecify, $type]; + if ($exprToSpecify !== $subject) { + $sureTypes[$this->exprPrinter->printExpr($subject)] = [$subject, $type]; + } + } + + $result = new SpecifiedTypes($sureTypes, $sureNotTypes); + if (isset($nullsafeFanTypes)) { + $result = $result->unionWith($nullsafeFanTypes); + } + + return $result; + } + + /** + * specifyDefaultTypes() plus the nullsafe receiver fan: the default truthy + * narrowing of a chain containing a nullsafe also narrows its receivers + * not-null (the old-world truthy default routed through create()'s + * nullsafe fan). The stored result is looked up at ask time. + */ + public function specifyDefaultTypesWithNullsafeFan(Expr $expr, TypeSpecifierContext $context, MutatingScope $beforeScope, bool $nativeTypesPromoted): SpecifiedTypes + { + $default = $this->specifyDefaultTypes($expr, $context); + if (!$context->truthy() || $context->falsey()) { + return $default; + } + + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $storage = $s->getCurrentExpressionResultStorage(); + $result = $storage !== null ? $storage->findExpressionResult($expr) : null; + if ($result === null) { + return $default; + } + + $fan = $this->createNullsafeReceiverOnlyTypes($s, $expr, $result, StaticTypeFactory::falsey(), TypeSpecifierContext::createFalse()); + + return $default->unionWith($fan)->setRootExpr($expr); + } + + /** + * Only the nullsafe receiver fan of a subject whose own value must not be + * remembered (an impure or otherwise non-narrowable call): the receiver + * chain still narrows not-null when the constraint rules the + * short-circuit null out - the chain must have evaluated for the call to + * produce any non-null value at all. + */ + public function createNullsafeReceiverOnlyTypes(MutatingScope $s, Expr $subject, ?ExpressionResult $subjectResult, Type $type, TypeSpecifierContext $context): SpecifiedTypes + { + if ( + $subjectResult === null + || !$subjectResult->containsNullsafe() + || !$this->nullsafeShortCircuitRuledOut($s, $subjectResult, $type, $context) + ) { + return new SpecifiedTypes([], []); + } + + return $this->createFirstNullsafeReceiverTypes($s, $subject) ?? new SpecifiedTypes([], []); + } + + /** + * Whether the constraint (or the subject's own type) rules the nullsafe + * short-circuit null out, so the chain's receivers can narrow not-null. + */ + private function nullsafeShortCircuitRuledOut(MutatingScope $s, ExpressionResult $subjectResult, Type $type, TypeSpecifierContext $context): bool + { + if ($context->true()) { + return $type->isNull()->no() || ($s->nativeTypesPromoted ? $subjectResult->getNativeType() : $subjectResult->getType())->isNull()->no(); + } + if ($context->false()) { + return TypeCombinator::containsNull($type) || ($s->nativeTypesPromoted ? $subjectResult->getNativeType() : $subjectResult->getType())->isNull()->no(); + } + + return false; + } + + /** + * Narrows the first nullsafe link below a plain fetch/call wrapper to + * "not null" (composing through its stored result, so its own receiver + * chain fans too) - the wrapper spine itself contributes nothing. + */ + private function createFirstNullsafeReceiverTypes(MutatingScope $s, Expr $expr): ?SpecifiedTypes + { + while (true) { + if ($expr instanceof Expr\NullsafePropertyFetch || $expr instanceof Expr\NullsafeMethodCall) { + $storage = $s->getCurrentExpressionResultStorage(); + + return $this->createSubjectTypes( + $s, + $expr, + $storage !== null ? $storage->findExpressionResult($expr) : null, + new NullType(), + TypeSpecifierContext::createFalse(), + ); + } + + if ($expr instanceof PropertyFetch || $expr instanceof MethodCall || $expr instanceof Expr\ArrayDimFetch) { + $expr = $expr->var; + continue; + } + + if (($expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\StaticCall) && $expr->class instanceof Expr) { + $expr = $expr->class; + continue; + } + + return null; + } + } + + /** + * The inside-out create() for a raw subject: narrows it through its own stored + * result's createTypesCallback, falling back to create() when there is none. + * When the caller already holds the subject's result (e.g. an operand a parent + * handler just processed) it passes a $resultFor lookup so composition uses that + * captured result directly instead of a storage lookup - so a remembered-wrapper + * operand fans out to wrapper + inner without the caller unwrapping it. + * + * @param (Closure(Expr): ?ExpressionResult)|null $resultFor + */ + public function createForSubject(Expr $subject, Type $type, TypeSpecifierContext $context, Scope $scope, ?Closure $resultFor = null): SpecifiedTypes + { + $mutatingScope = $scope->toMutatingScope(); + $subjectResult = $resultFor !== null ? $resultFor($subject) : null; + + $storage = $mutatingScope->getCurrentExpressionResultStorage(); + + return $this->createSubjectTypes( + $mutatingScope, + $subject, + $subjectResult ?? ($storage !== null ? $storage->findExpressionResult($subject) : null), + $type, + $context, + ); + } + + /** + * Captures the stored ExpressionResults of an isset/empty/?? subject's + * chain links (the results, not the storage - no reference cycle) so + * narrowing callbacks read their types instead of re-walking the chain. + * + * @param array $chainResults + */ + public function captureChainResults(Expr $node, ExpressionResultStorage $storage, array &$chainResults): void + { + $result = $storage->findExpressionResult($node); + if ($result !== null) { + $chainResults[spl_object_id($node)] = $result; + } + + if ($node instanceof ArrayDimFetch) { + $this->captureChainResults($node->var, $storage, $chainResults); + if ($node->dim !== null) { + $this->captureChainResults($node->dim, $storage, $chainResults); + } + } elseif ($node instanceof PropertyFetch) { + $this->captureChainResults($node->var, $storage, $chainResults); + } elseif ($node instanceof StaticPropertyFetch && $node->class instanceof Expr) { + $this->captureChainResults($node->class, $storage, $chainResults); + } + } + + /** + * The chain-link type reader for the captured results: every link resolves + * through its captured result on the asking scope (honouring narrowing) - + * captureChainResults() captured the whole chain from the walk's storage. + * + * @param array $chainResults + * @return Closure(Expr): Type + */ + public function buildChainTypeReader(array $chainResults, MutatingScope $s): Closure + { + return static function (Expr $e) use ($chainResults, $s): Type { + $result = $chainResults[spl_object_id($e)] ?? null; + if ($result === null) { + throw new ShouldNotHappenException(); + } + + return $result->getTypeOnScope($s, $s->nativeTypesPromoted); + }; + } + + /** + * The truthy narrowing of isset($issetExpr), composed from the subject's + * chain: per-link HasOffset/NonEmptyArray/HasProperty facts plus a not-null + * entry for every link - exactly what the Isset_ handler emits in the true + * context. Lets ?? narrow its left side without synthesizing an Isset_ node + * and re-walking the chain on demand. + * + * @param Closure(Expr): Type $readType + */ + public function createIssetTruthyChainTypes(MutatingScope $s, Expr $issetExpr, Closure $readType, Expr $rootExpr, TypeSpecifierContext $context): SpecifiedTypes + { + $tmpVars = [$issetExpr]; + while ( + $issetExpr instanceof ArrayDimFetch + || $issetExpr instanceof PropertyFetch + || ( + $issetExpr instanceof StaticPropertyFetch + && $issetExpr->class instanceof Expr + ) + ) { + if ($issetExpr instanceof StaticPropertyFetch) { + /** @var Expr $issetExpr */ + $issetExpr = $issetExpr->class; + } else { + $issetExpr = $issetExpr->var; + } + $tmpVars[] = $issetExpr; + } + $vars = array_reverse($tmpVars); + + $types = new SpecifiedTypes(); + foreach ($vars as $var) { + + if ($var instanceof Expr\Variable && is_string($var->name)) { + if ($s->hasVariableType($var->name)->no()) { + return (new SpecifiedTypes([], []))->setRootExpr($rootExpr); + } + } + + if ( + $var instanceof ArrayDimFetch + && $var->dim !== null + && !$readType($var->var) instanceof MixedType + ) { + $dimType = $readType($var->dim); + + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $types = $types->unionWith( + $this->createForSubject( + $var->var, + new HasOffsetType($dimType), + $context, + $s, + )->setRootExpr($rootExpr), + ); + } else { + $varType = $readType($var->var); + + $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); + if ($narrowedKey !== null) { + $types = $types->unionWith( + $this->createForSubject( + $var->dim, + $narrowedKey, + $context, + $s, + )->setRootExpr($rootExpr), + ); + } + + if ($varType->isArray()->yes()) { + $types = $types->unionWith( + $this->createForSubject( + $var->var, + new NonEmptyArrayType(), + $context, + $s, + )->setRootExpr($rootExpr), + ); + } + } + } + + if ( + $var instanceof PropertyFetch + && $var->name instanceof Identifier + ) { + $types = $types->unionWith( + $this->createForSubject($var->var, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($rootExpr), + ); + } elseif ( + $var instanceof StaticPropertyFetch + && $var->class instanceof Expr + && $var->name instanceof VarLikeIdentifier + ) { + $types = $types->unionWith( + $this->createForSubject($var->class, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($rootExpr), + ); + } + + $types = $types->unionWith( + $this->createForSubject($var, new NullType(), TypeSpecifierContext::createFalse(), $s)->setRootExpr($rootExpr), + ); + } + + return $types; + } + + /** + * The non-true narrowing of a single isset() subject, composed from its + * captured result - shared by IssetHandler's paths and empty()'s disjunction. + * + * @param callable(Expr): Type $readType + */ + public function createIssetSingleSubjectNonTrueTypes( + MutatingScope $s, + Expr $issetExpr, + ExpressionResult $varResult, + callable $readType, + TypeSpecifierContext $context, + Expr $rootExpr, + ): SpecifiedTypes + { + $isset = $varResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + + if ($isset === false) { + return new SpecifiedTypes(); + } + + $type = $readType($issetExpr); + $isNullable = !$type->isNull()->no(); + $exprType = $this->createForSubject( + $issetExpr, + new NullType(), + $context->negate(), + $s, + )->setRootExpr($rootExpr); + + if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { + if ($isset === true) { + if ($isNullable) { + return $exprType; + } + + // variable cannot exist in !isset() + return $exprType->unionWith($this->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + ))->setRootExpr($rootExpr); + } + + if ($isNullable) { + // reduces variable certainty to maybe + return $exprType->unionWith($this->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context->negate(), + $s, + ))->setRootExpr($rootExpr); + } + + // variable cannot exist in !isset() + return $this->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + )->setRootExpr($rootExpr); + } + + if ($isNullable && $isset === true) { + return $exprType; + } + + // A maybe verdict on a native-typed property whose inner chain is fully + // set can only mean "nullable value" or "maybe uninitialized". Reading an + // uninitialized typed property throws instead of yielding a value, so in + // the !isset() branch any read that completes yields null - the null pin + // is sound for both. + if ($isset === null && $isNullable) { + $resolution = $varResult->getIssetabilityResolution($s, false); + $link = $resolution->getLink(); + $inner = $resolution->getInner(); + if ( + $link->isProperty() + && $link->isReflectionNative() + && $link->hasNativeType() + && !$link->isVirtual()->yes() + && ($inner === null || $inner->isSet(static fn (): bool => true) === true) + ) { + return $exprType; + } + } + + if ( + $issetExpr instanceof ArrayDimFetch + && $issetExpr->dim !== null + // When the var is itself an offset access (a nested isset like + // $r['K']['Port']), narrowing it in the falsey branch leaks the + // intermediate offset's existence into the enclosing scope. + && !($issetExpr->var instanceof ArrayDimFetch) + ) { + $varType = $readType($issetExpr->var); + if (!$varType instanceof MixedType) { + $dimType = $readType($issetExpr->dim); + + if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { + $constantArrays = $varType->getConstantArrays(); + $typesToRemove = []; + foreach ($constantArrays as $constantArray) { + $hasOffset = $constantArray->hasOffsetValueType($dimType); + if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { + continue; + } + + $typesToRemove[] = $constantArray; + } + + if ($typesToRemove !== []) { + $typeToRemove = TypeCombinator::union(...$typesToRemove); + + $result = $this->createForSubject( + $issetExpr->var, + $typeToRemove, + TypeSpecifierContext::createFalse(), + $s, + )->setRootExpr($rootExpr); + + if ($s->hasExpressionType($issetExpr->var)->maybe()) { + $result = $result->unionWith( + $this->createForSubject( + new IssetExpr($issetExpr->var), + new NullType(), + TypeSpecifierContext::createTruthy(), + $s, + )->setRootExpr($rootExpr), + ); + } + + return $result; + } + } + } + } + + return new SpecifiedTypes(); + } + + /** + * The narrowing a call's @phpstan-assert tags contribute - the new-world + * home of TypeSpecifier::specifyTypesFromAsserts(). Subjects and argument + * types are read through their ExpressionResults (stored, or priced once + * for out-of-frame asks), so createSubjectTypes() composes with the + * result-carried structure (impure gate, nullsafe twin) instead of + * create()'s scope re-probing. + */ + public function specifyTypesFromAsserts(TypeSpecifierContext $context, CallLike $call, Assertions $assertions, ParametersAcceptor $parametersAcceptor, MutatingScope $scope): ?SpecifiedTypes + { + if ($context->null()) { + $asserts = $assertions->getAsserts(); + } elseif ($context->true()) { + $asserts = $assertions->getAssertsIfTrue(); + } elseif ($context->false()) { + $asserts = $assertions->getAssertsIfFalse(); + } else { + throw new ShouldNotHappenException(); + } + + if (count($asserts) === 0) { + return null; + } + + $argsMap = []; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + $lastParameter = array_last($parameters); + $paramName = $lastParameter->getName(); + } else { + continue; + } + + $argsMap[$paramName][] = $arg->value; + } + foreach ($parameters as $parameter) { + $name = $parameter->getName(); + $defaultValue = $parameter->getDefaultValue(); + if (isset($argsMap[$name]) || $defaultValue === null) { + continue; + } + $argsMap[$name][] = new TypeExpr($defaultValue); + } + + if ($call instanceof MethodCall) { + $argsMap['this'] = [$call->var]; + } + + $getArgType = static function (Expr $expr) use ($scope): Type { + if ($expr instanceof TypeExpr) { + return $expr->getExprType(); + } + + $storage = $scope->getCurrentExpressionResultStorage(); + $result = $storage !== null ? $storage->findExpressionResult($expr) : null; + if ($result !== null) { + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + return $scope->getStateType($expr); + }; + + /** @var SpecifiedTypes|null $types */ + $types = null; + + foreach ($asserts as $assert) { + foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) { + $assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $getArgType): Type { + if ($type instanceof ConditionalTypeForParameter) { + $parameterName = substr($type->getParameterName(), 1); + if (array_key_exists($parameterName, $argsMap)) { + $type = $traverse($type); + if ($type instanceof ConditionalTypeForParameter) { + $argType = TypeCombinator::union(...array_map($getArgType, $argsMap[substr($type->getParameterName(), 1)])); + return $type->toConditional($argType); + } + return $type; + } + } + + return $traverse($type); + }); + + $assertExpr = $assert->getParameter()->getExpr($parameterExpr); + + $templateTypeMap = $parametersAcceptor->getResolvedTemplateTypeMap(); + $containsUnresolvedTemplate = false; + TypeTraverser::map( + $assert->getOriginalType(), + static function (Type $type, callable $traverse) use ($templateTypeMap, &$containsUnresolvedTemplate) { + if ($type instanceof TemplateType && $type->getScope()->getClassName() !== null) { + $resolvedType = $templateTypeMap->getType($type->getName()); + if ($resolvedType === null || $type->getBound()->equals($resolvedType)) { + $containsUnresolvedTemplate = true; + return $type; + } + } + + return $traverse($type); + }, + ); + + $assertStorage = $scope->getCurrentExpressionResultStorage(); + $subjectResult = $assertExpr instanceof TypeExpr || $assertStorage === null + ? null + : $assertStorage->findExpressionResult($assertExpr); + if ($subjectResult === null && $assertExpr instanceof CallLike && !$this->mayRememberCallSubject($scope, $assertExpr)) { + // a call subject whose value must not be remembered (side + // effects) contributes no narrowing - old create()'s purity + // gate, derived from reflection instead of a walk + continue; + } + $newTypes = $this->createSubjectTypes( + $scope, + $assertExpr, + $subjectResult, + $assertedType, + $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), + )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); + $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; + + if (!$context->null() || (!$assertedType->isTrue()->yes() && !$assertedType->isFalse()->yes())) { + continue; + } + + $subContext = $assertedType->isTrue()->yes() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + if ($assert->isNegated()) { + $subContext = $subContext->negate(); + } + + $types = $types->unionWith($this->specifyTypesForNode( + $scope, + $assertExpr, + $subContext, + )); + } + } + + return $types; + } + + /** + * The narrowing a conditional return type (`($x is Foo ? true : false)`) + * contributes to its argument - the new-world home of + * TypeSpecifier::specifyTypesFromConditionalReturnType(). The argument's + * narrowing composes through its ExpressionResult. + */ + public function specifyTypesFromConditionalReturnType( + TypeSpecifierContext $context, + Expr\CallLike $call, + ParametersAcceptor $parametersAcceptor, + MutatingScope $scope, + ): ?SpecifiedTypes + { + if (!$parametersAcceptor instanceof ResolvedFunctionVariant) { + return null; + } + + $returnType = $parametersAcceptor->getOriginalParametersAcceptor()->getReturnType(); + if (!$returnType instanceof ConditionalTypeForParameter) { + return null; + } + + if ($context->true()) { + $leftType = new ConstantBooleanType(true); + $rightType = new ConstantBooleanType(false); + } elseif ($context->false()) { + $leftType = new ConstantBooleanType(false); + $rightType = new ConstantBooleanType(true); + } elseif ($context->null()) { + $leftType = new MixedType(); + $rightType = new NeverType(); + } else { + return null; + } + + $argumentExpr = null; + $parameters = $parametersAcceptor->getParameters(); + foreach ($call->getArgs() as $i => $arg) { + if ($arg->unpack) { + continue; + } + + if ($arg->name !== null) { + $paramName = $arg->name->toString(); + } elseif (isset($parameters[$i])) { + $paramName = $parameters[$i]->getName(); + } else { + continue; + } + + if ($returnType->getParameterName() !== '$' . $paramName) { + continue; + } + + $argumentExpr = $arg->value; + } + + if ($argumentExpr === null) { + return null; + } + + return $this->getConditionalSpecifiedTypes($returnType, $leftType, $rightType, $scope, $argumentExpr); + } + + private function getConditionalSpecifiedTypes( + ConditionalTypeForParameter $conditionalType, + Type $leftType, + Type $rightType, + MutatingScope $scope, + Expr $argumentExpr, + ): ?SpecifiedTypes + { + $targetType = $conditionalType->getTarget(); + $ifType = $conditionalType->getIf(); + $elseType = $conditionalType->getElse(); + + if ( + ( + $argumentExpr instanceof Node\Scalar + || ($argumentExpr instanceof ConstFetch && in_array(strtolower($argumentExpr->name->toString()), ['true', 'false', 'null'], true)) + ) && ($ifType instanceof NeverType || $elseType instanceof NeverType) + ) { + return null; + } + + if ($leftType->isSuperTypeOf($ifType)->yes() && $rightType->isSuperTypeOf($elseType)->yes()) { + $context = $conditionalType->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(); + } elseif ($leftType->isSuperTypeOf($elseType)->yes() && $rightType->isSuperTypeOf($ifType)->yes()) { + $context = $conditionalType->isNegated() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + } else { + return null; + } + + $argumentStorage = $scope->getCurrentExpressionResultStorage(); + $argumentResult = $argumentStorage !== null ? $argumentStorage->findExpressionResult($argumentExpr) : null; + if ($argumentResult === null && $argumentExpr instanceof CallLike && !$this->mayRememberCallSubject($scope, $argumentExpr)) { + // old create()'s purity gate, derived from reflection instead of a walk + return null; + } + $specifiedTypes = $this->createSubjectTypes( + $scope, + $argumentExpr, + $argumentResult, + $targetType, + $context, + ); + + if ($targetType->isTrue()->yes() || $targetType->isFalse()->yes()) { + if ($targetType->isFalse()->yes()) { + $context = $context->negate(); + } + + $specifiedTypes = $specifiedTypes->unionWith($this->specifyTypesForNode($scope, $argumentExpr, $context)); + } + + return $specifiedTypes; + } + + /** + * Whether a call subject's value may be remembered by narrowing - the + * walk-free equivalent of the impure gate a stored ExpressionResult + * carries: a call with (possible) side effects yields a different value + * next time, so pinning a type to its expression string would lie. + */ + private function mayRememberCallSubject(MutatingScope $scope, Expr $expr): bool + { + if ($expr instanceof Expr\FuncCall && $expr->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + return false; + } + $hasSideEffects = $this->reflectionProvider->getFunction($expr->name, $scope)->hasSideEffects(); + } elseif ($expr instanceof Expr\MethodCall && $expr->name instanceof Identifier) { + $methodReflection = $scope->getMethodReflection($scope->getStateType($expr->var), $expr->name->toString()); + if ($methodReflection === null) { + return false; + } + $hasSideEffects = $methodReflection->hasSideEffects(); + } elseif ($expr instanceof Expr\StaticCall && $expr->name instanceof Identifier && $expr->class instanceof Name) { + $methodReflection = $scope->getMethodReflection($scope->resolveTypeByName($expr->class), $expr->name->toString()); + if ($methodReflection === null) { + return false; + } + $hasSideEffects = $methodReflection->hasSideEffects(); + } else { + return false; + } + + if ($hasSideEffects->yes()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); + } + +} From 0501aa7d3b71d08c1709f323c6bd671cc1807111 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:32 +0200 Subject: [PATCH 05/32] Replace EqualityTypeSpecifyingHelper with result-composed IdenticalNarrowingHelper The equality narrowing (===, !==, ==, != and the specifying-function families driven by them) is rebuilt result-first: IdenticalNarrowingHelper composes the narrowing from the two operands' ExpressionResults, and specifyIdenticalAgainstType() serves callers that have no comparison node at all (assign-time conditional holders, switch cases, foreach exhaustiveness). BinaryOpHandler routes all four comparison operators through it with context negation instead of synthetic BooleanNot walks, and CastHandler narrows bool/int/double casts through a composed comparison against a fabricated literal. equality-narrowing-new-world.php pins the behaviour of every rewritten family; the class-name comparison fixtures cover ::class comparisons against unknown classes and the guard that a non-::class constant fetch does not narrow the object it is fetched on. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/ExprHandler/BinaryOpHandler.php | 955 +++++++------ src/Analyser/ExprHandler/CastHandler.php | 94 +- .../Helper/EqualityTypeSpecifyingHelper.php | 952 ------------- .../Helper/IdenticalNarrowingHelper.php | 1183 +++++++++++++++++ .../class-constant-comparison-narrowing.php | 43 + .../class-name-comparison-unknown-class.php | 23 + .../nsrt/equality-narrowing-new-world.php | 541 ++++++++ 7 files changed, 2399 insertions(+), 1392 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php create mode 100644 src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php create mode 100644 tests/PHPStan/Analyser/nsrt/class-constant-comparison-narrowing.php create mode 100644 tests/PHPStan/Analyser/nsrt/class-name-comparison-unknown-class.php create mode 100644 tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php diff --git a/src/Analyser/ExprHandler/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index f8df18444ef..217edd6a75a 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -17,15 +17,15 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\EqualityTypeSpecifyingHelper; +use PHPStan\Analyser\ExprHandler\Helper\CountNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\RicherScopeGetTypeHelper; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Printer\ExprPrinter; @@ -50,6 +50,7 @@ use function get_class; use function in_array; use function is_string; +use function spl_object_id; use function sprintf; use function strtolower; @@ -66,8 +67,10 @@ public function __construct( private PhpVersion $phpVersion, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExprPrinter $exprPrinter, - private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, + private CountNarrowingHelper $countNarrowingHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -92,486 +95,634 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()); if ( ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && + // the right operand was just processed on $leftResult's scope; read its + // result instead of re-walking via Scope::getType(). !$rightResult->getType()->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($leftResult->getScope(), new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof BinaryOp\Concat) { - $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $scope); - $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $leftResult->getScope()); + $leftToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->left, $scope, $leftResult); + $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->right, $leftResult->getScope(), $rightResult); $throwPoints = array_merge($throwPoints, $leftToStringResult->getThrowPoints(), $rightToStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $leftToStringResult->getImpurePoints(), $rightToStringResult->getImpurePoints()); } $scope = $rightResult->getScope(); - return $this->expressionResultFactory->create( - $scope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: $leftResult->hasYield() || $rightResult->hasYield(), - isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), - throwPoints: $throwPoints, - impurePoints: $impurePoints, - ); - } + $leftArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->left, $storage); + $rightArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->right, $storage); + // the comparison specify logic reads these operand subexpressions (count() + // arguments, subtraction operands) - capture their walk results now: the + // callback must not capture the storage itself (the storage holds the + // results and the results hold their callbacks - a cycle the disabled GC + // never collects) + $specifySubResults = []; + $specifySubExprs = []; + if ($expr->right instanceof Expr\FuncCall && !$expr->right->isFirstClassCallable() && isset($expr->right->getArgs()[0])) { + $specifySubExprs[] = $expr->right->getArgs()[0]->value; + } elseif ($expr->right instanceof BinaryOp\Minus) { + $specifySubExprs[] = $expr->right->right; + if ($expr->right->left instanceof Expr\FuncCall && !$expr->right->left->isFirstClassCallable() && isset($expr->right->left->getArgs()[0])) { + $specifySubExprs[] = $expr->right->left->getArgs()[0]->value; + } + } + foreach ($specifySubExprs as $specifySubExpr) { + $specifySubResult = $storage->findExpressionResult($specifySubExpr); + if ($specifySubResult === null) { + continue; + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $getType = static fn (Expr $expr): Type => $scope->getType($expr); + $specifySubResults[spl_object_id($specifySubExpr)] = $specifySubResult; + } + + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { + // the comparison helpers (resolveEqualType / RicherScopeGetTypeHelper) + // read the operand types off the evaluation scope - native-promote it + // here so the native flavour is honoured. + $scope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + // the operands were processed during processExpr; read their already + // computed results instead of re-walking via Scope::getType(). + // Synthetic nodes the resolver builds (e.g. getDivType's Mod) are + // priced on demand by the same helper. + $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $nativeTypesPromoted, $beforeScope, $nodeScopeResolver): Type { + // getTypeOnScope re-prices narrowable operands against this + // result's OWN beforeScope: for the main walk that is the walk + // position (identical to getType()), but for an on-demand walk + // of a synthetic (a rule asking about Identical($x, ...) on an + // arm-narrowed scope) it is the asking scope, whose tracked + // narrowing the stored operand results predate + $flavouredScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + // operands are re-priced from this result's own beforeScope: + // for the main walk that is the walk position, but for an + // on-demand walk of a synthetic (a rule asking about + // Identical($x, ...) on an arm-narrowed scope) it carries + // narrowing the stored operand results predate + if ($e === $expr->left) { + return $leftResult->getTypeOnScope($flavouredScope, $nativeTypesPromoted); + } + if ($e === $expr->right) { + return $rightResult->getTypeOnScope($flavouredScope, $nativeTypesPromoted); + } - if ($expr instanceof BinaryOp\Smaller) { - return $scope->getType($expr->left)->isSmallerThan($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); - } + // InitializerExprTypeResolver also asks about synthetic composed + // nodes (e.g. Mod($left, $right) for the int-division check) - + // price those + return $nodeScopeResolver->processSyntheticOnDemand($e, $flavouredScope)->getTypeOnScope($flavouredScope, $flavouredScope->nativeTypesPromoted); + }; - if ($expr instanceof BinaryOp\SmallerOrEqual) { - return $scope->getType($expr->left)->isSmallerThanOrEqual($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\Smaller) { + return $getType($expr->left)->isSmallerThan($getType($expr->right), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\Greater) { - return $scope->getType($expr->right)->isSmallerThan($scope->getType($expr->left), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\SmallerOrEqual) { + return $getType($expr->left)->isSmallerThanOrEqual($getType($expr->right), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\GreaterOrEqual) { - return $scope->getType($expr->right)->isSmallerThanOrEqual($scope->getType($expr->left), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\Greater) { + return $getType($expr->right)->isSmallerThan($getType($expr->left), $this->phpVersion)->toBooleanType(); + } - if ($expr instanceof BinaryOp\Equal) { - if ( - $expr->left instanceof Variable - && is_string($expr->left->name) - && $expr->right instanceof Variable - && is_string($expr->right->name) - && $expr->left->name === $expr->right->name - ) { - return new ConstantBooleanType(true); + if ($expr instanceof BinaryOp\GreaterOrEqual) { + return $getType($expr->right)->isSmallerThanOrEqual($getType($expr->left), $this->phpVersion)->toBooleanType(); } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + if ($expr instanceof BinaryOp\Equal) { + return $this->resolveEqualType($scope, $expr, $leftResult, $rightResult); + } - return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; - } + if ($expr instanceof BinaryOp\NotEqual) { + // negation of the Equal result - direct computation avoids + // synthesizing a BooleanNot node (which would route through + // on-demand re-processing once BooleanNot is migrated) + $equalType = $this->resolveEqualType($scope, new BinaryOp\Equal($expr->left, $expr->right), $leftResult, $rightResult)->toBoolean(); + if ($equalType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($equalType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } - if ($expr instanceof BinaryOp\NotEqual) { - return $scope->getType(new Expr\BooleanNot(new BinaryOp\Equal($expr->left, $expr->right))); - } + return new BooleanType(); + } - if ($expr instanceof BinaryOp\Identical) { - return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr)->type; - } + if ($expr instanceof BinaryOp\Identical) { + return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr, $nodeScopeResolver, $getType($expr->left), $getType($expr->right))->type; + } - if ($expr instanceof BinaryOp\NotIdentical) { - return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr)->type; - } + if ($expr instanceof BinaryOp\NotIdentical) { + return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr, $nodeScopeResolver, $getType($expr->left), $getType($expr->right))->type; + } + + if ($expr instanceof BinaryOp\LogicalXor) { + $leftBooleanType = $getType($expr->left)->toBoolean(); + $rightBooleanType = $getType($expr->right)->toBoolean(); + + if ( + $leftBooleanType instanceof ConstantBooleanType + && $rightBooleanType instanceof ConstantBooleanType + ) { + return new ConstantBooleanType( + $leftBooleanType->getValue() xor $rightBooleanType->getValue(), + ); + } - if ($expr instanceof BinaryOp\LogicalXor) { - $leftBooleanType = $scope->getType($expr->left)->toBoolean(); - $rightBooleanType = $scope->getType($expr->right)->toBoolean(); - - if ( - $leftBooleanType instanceof ConstantBooleanType - && $rightBooleanType instanceof ConstantBooleanType - ) { - return new ConstantBooleanType( - $leftBooleanType->getValue() xor $rightBooleanType->getValue(), - ); + return new BooleanType(); } - return new BooleanType(); - } + if ($expr instanceof BinaryOp\Spaceship) { + return $this->initializerExprTypeResolver->getSpaceshipType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Spaceship) { - return $this->initializerExprTypeResolver->getSpaceshipType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Concat) { - return $this->initializerExprTypeResolver->getConcatType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\BitwiseAnd) { - return $this->initializerExprTypeResolver->getBitwiseAndType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\BitwiseOr) { - return $this->initializerExprTypeResolver->getBitwiseOrType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\BitwiseXor) { - return $this->initializerExprTypeResolver->getBitwiseXorType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Div) { + return $this->initializerExprTypeResolver->getDivType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Div) { - return $this->initializerExprTypeResolver->getDivType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Mod) { + return $this->initializerExprTypeResolver->getModType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Mod) { - return $this->initializerExprTypeResolver->getModType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Plus) { - return $this->initializerExprTypeResolver->getPlusType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Minus) { - return $this->initializerExprTypeResolver->getMinusType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Mul) { - return $this->initializerExprTypeResolver->getMulType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\Pow) { - return $this->initializerExprTypeResolver->getPowType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\ShiftLeft) { - return $this->initializerExprTypeResolver->getShiftLeftType($expr->left, $expr->right, $getType); - } + if ($expr instanceof BinaryOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($expr->left, $expr->right, $getType); + } - if ($expr instanceof BinaryOp\ShiftRight) { - return $this->initializerExprTypeResolver->getShiftRightType($expr->left, $expr->right, $getType); - } + throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); + }; - throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); - } + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $leftResult->hasYield() || $rightResult->hasYield(), + isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), + throwPoints: $throwPoints, + impurePoints: $impurePoints, + typeCallback: $typeCallback, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $leftResult, $rightResult, $nodeScopeResolver, $beforeScope, $specifySubResults, $leftArgResult, $rightArgResult, $typeCallback): SpecifiedTypes { + $scope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if ($expr instanceof BinaryOp\Identical || $expr instanceof BinaryOp\NotIdentical) { + // `!==` narrowing is the `===` narrowing in the negated context - + // no synthetic Identical node. A null context never negates. + if ($context->null() && $expr instanceof BinaryOp\NotIdentical) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($expr instanceof BinaryOp\Identical) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($expr, $scope, $context); - } + $newWorldTypes = $this->identicalNarrowingHelper->specifyIdentical( + $nodeScopeResolver, + $expr->left, + $expr->right, + $leftResult, + $rightResult, + $expr instanceof BinaryOp\NotIdentical ? $context->negate() : $context, + // the narrowing composes on the evaluation scope; only the + // asked flavour comes from the asking scope + $scope, + $leftArgResult, + $rightArgResult, + // the comparison's own verdict, in Identical semantics - + // computed from the captured operand results (the walk's + // evaluation point), only the flavour follows the ask + static function () use ($expr, $nativeTypesPromoted, $typeCallback): Type { + $ownType = $typeCallback($nativeTypesPromoted); + if ($expr instanceof BinaryOp\NotIdentical) { + if ($ownType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($ownType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + } - if ($expr instanceof BinaryOp\NotIdentical) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BooleanNot(new BinaryOp\Identical($expr->left, $expr->right)), - $context, - )->setRootExpr($expr); - } + return $ownType; + }, + ); - if ($expr instanceof BinaryOp\Equal) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($expr, $scope, $context); - } + // null = no shape-specific narrowing (unknown-class ::class, + // null-context asks) - the default is all that remains + return ($newWorldTypes ?? $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context))->setRootExpr($expr); + } - if ($expr instanceof BinaryOp\NotEqual) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BooleanNot(new BinaryOp\Equal($expr->left, $expr->right)), - $context, - )->setRootExpr($expr); - } + if ($expr instanceof BinaryOp\Equal || $expr instanceof BinaryOp\NotEqual) { + // `!=` narrowing is the `==` narrowing in the negated context + if ($context->null() && $expr instanceof BinaryOp\NotEqual) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - if ($expr instanceof BinaryOp\Smaller || $expr instanceof BinaryOp\SmallerOrEqual) { - if ( - $expr->left instanceof Expr\FuncCall - && $expr->left->name instanceof Name - && !$expr->left->isFirstClassCallable() - && in_array(strtolower((string) $expr->left->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) - && count($expr->left->getArgs()) >= 1 - && ( - !$expr->right instanceof Expr\FuncCall - || !$expr->right->name instanceof Name - || !in_array(strtolower((string) $expr->right->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) - ) - ) { - $inverseOperator = $expr instanceof BinaryOp\Smaller - ? new BinaryOp\SmallerOrEqual($expr->right, $expr->left) - : new BinaryOp\Smaller($expr->right, $expr->left); - - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BooleanNot($inverseOperator), - $context, - )->setRootExpr($expr); - } + $newWorldTypes = $this->identicalNarrowingHelper->specifyEqual( + $nodeScopeResolver, + $expr->left, + $expr->right, + $leftResult, + $rightResult, + $expr instanceof BinaryOp\NotEqual ? $context->negate() : $context, + $scope, + $leftArgResult, + $rightArgResult, + ); - $orEqual = $expr instanceof BinaryOp\SmallerOrEqual; - $offset = $orEqual ? 0 : 1; - $leftType = $scope->getType($expr->left); - $result = (new SpecifiedTypes([], []))->setRootExpr($expr); - - if ( - !$context->null() - && $expr->right instanceof Expr\FuncCall - && $expr->right->name instanceof Name - && !$expr->right->isFirstClassCallable() - && in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true) - && count($expr->right->getArgs()) >= 1 - && $leftType->isInteger()->yes() - ) { - $argType = $scope->getType($expr->right->getArgs()[0]->value); - - $sizeType = null; - if ($leftType instanceof ConstantIntegerType) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); - } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); + return ($newWorldTypes ?? $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context))->setRootExpr($expr); + } + + if ($expr instanceof BinaryOp\Smaller || $expr instanceof BinaryOp\SmallerOrEqual) { + if ( + $expr->left instanceof Expr\FuncCall + && $expr->left->name instanceof Name + && !$expr->left->isFirstClassCallable() + && in_array(strtolower((string) $expr->left->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) + && count($expr->left->getArgs()) >= 1 + && ( + !$expr->right instanceof Expr\FuncCall + || !$expr->right->name instanceof Name + || !in_array(strtolower((string) $expr->right->name), ['count', 'sizeof', 'strlen', 'mb_strlen', 'preg_match'], true) + ) + ) { + $inverseOperator = $expr instanceof BinaryOp\Smaller + ? new BinaryOp\SmallerOrEqual($expr->right, $expr->left) + : new BinaryOp\Smaller($expr->right, $expr->left); + + // negating the context is exactly what a BooleanNot around the + // inverse operator would do - direct computation avoids + // synthesizing a BooleanNot node. A null context never negates + // (BooleanNot defaults on it too). + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + return $this->defaultNarrowingHelper->specifyTypesForNode( + $scope, + $inverseOperator, + $context->negate(), + )->setRootExpr($expr); } - } elseif ($leftType instanceof IntegerRangeType) { - if ($context->falsey() && $leftType->getMax() !== null) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()); - } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); + + $orEqual = $expr instanceof BinaryOp\SmallerOrEqual; + $offset = $orEqual ? 0 : 1; + // the operands were processed during processExpr; read their + // already computed results instead of re-walking via + // Scope::getType(). Their subexpressions (e.g. count() arguments) + // were also processed and are read from the stored result. + $getType = static function (Expr $e) use ($expr, $leftResult, $rightResult, $scope, $specifySubResults, $nativeTypesPromoted): Type { + if ($e === $expr->left) { + return $nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(); + } + if ($e === $expr->right) { + return $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); + } + + // the remaining asks are operand subexpressions whose walk + // results were captured at creation + $result = $specifySubResults[spl_object_id($e)] ?? null; + if ($result === null) { + throw new ShouldNotHappenException(); } - } elseif ($context->truthy() && $leftType->getMin() !== null) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); + + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + }; + $leftType = $getType($expr->left); + $result = (new SpecifiedTypes([], []))->setRootExpr($expr); + + if ( + !$context->null() + && $expr->right instanceof Expr\FuncCall + && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() + && in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true) + && count($expr->right->getArgs()) >= 1 + && $leftType->isInteger()->yes() + ) { + $argType = $getType($expr->right->getArgs()[0]->value); + + $sizeType = null; + if ($leftType instanceof ConstantIntegerType) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue()); + } + } elseif ($leftType instanceof IntegerRangeType) { + if ($context->falsey() && $leftType->getMax() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax()); + } + } elseif ($context->truthy() && $leftType->getMin() !== null) { + if ($orEqual) { + $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); + } else { + $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); + } + } } else { - $sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin()); + $sizeType = $leftType; } - } - } else { - $sizeType = $leftType; - } - if ($sizeType !== null) { - $specifiedTypes = $typeSpecifier->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - $result = $result->unionWith($specifiedTypes); - } - } + if ($sizeType !== null) { + $specifiedTypes = $this->countNarrowingHelper->specifyCountSize($expr->right, $argType, $sizeType, $context, $scope, $expr); + if ($specifiedTypes !== null) { + $result = $result->unionWith($specifiedTypes); + } + } - if ( - $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) - || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) - ) { - if ($context->truthy() && $argType->isArray()->maybe()) { - $countables = []; - if ($argType instanceof UnionType) { - $countableInterface = new ObjectType(Countable::class); - foreach ($argType->getTypes() as $innerType) { - if ($innerType->isArray()->yes()) { - $innerType = TypeCombinator::intersect(new NonEmptyArrayType(), $innerType); - $countables[] = $innerType; + if ( + $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) + || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) + ) { + if ($context->truthy() && $argType->isArray()->maybe()) { + $countables = []; + if ($argType instanceof UnionType) { + $countableInterface = new ObjectType(Countable::class); + foreach ($argType->getTypes() as $innerType) { + if ($innerType->isArray()->yes()) { + $innerType = TypeCombinator::intersect(new NonEmptyArrayType(), $innerType); + $countables[] = $innerType; + } + + if (!$countableInterface->isSuperTypeOf($innerType)->yes()) { + continue; + } + + $countables[] = $innerType; + } } - if (!$countableInterface->isSuperTypeOf($innerType)->yes()) { - continue; + if (count($countables) > 0) { + $countableType = TypeCombinator::union(...$countables); + + return $this->defaultNarrowingHelper->createForSubject($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); } + } - $countables[] = $innerType; + if ($argType->isArray()->yes()) { + $newType = new NonEmptyArrayType(); + if ($context->true() && $argType->isList()->yes()) { + $newType = TypeCombinator::intersect($newType, new AccessoryArrayListType()); + } + + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), + ); } } - if (count($countables) > 0) { - $countableType = TypeCombinator::union(...$countables); - - return $typeSpecifier->create($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); + // infer $list[$index] after $index < count($list) + if ( + $context->true() + && !$orEqual + // constant offsets are handled via HasOffsetType/HasOffsetValueType + && !$leftType instanceof ConstantIntegerType + && $argType->isList()->yes() + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() + ) { + $arrayArg = $expr->right->getArgs()[0]->value; + $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), + ); } } - if ($argType->isArray()->yes()) { - $newType = new NonEmptyArrayType(); - if ($context->true() && $argType->isList()->yes()) { - $newType = TypeCombinator::intersect($newType, new AccessoryArrayListType()); + // infer $list[$index] after $zeroOrMore < count($list) - N + // infer $list[$index] after $zeroOrMore <= count($list) - N + if ( + $context->true() + && $expr->right instanceof BinaryOp\Minus + && $expr->right->left instanceof Expr\FuncCall + && $expr->right->left->name instanceof Name + && !$expr->right->left->isFirstClassCallable() + && in_array(strtolower((string) $expr->right->left->name), ['count', 'sizeof'], true) + && count($expr->right->left->getArgs()) >= 1 + // constant offsets are handled via HasOffsetType/HasOffsetValueType + && !$leftType instanceof ConstantIntegerType + && $leftType->isInteger()->yes() + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() + ) { + $countArgType = $getType($expr->right->left->getArgs()[0]->value); + $subtractedType = $getType($expr->right->right); + if ( + $countArgType->isList()->yes() + && $this->countNarrowingHelper->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() + && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($subtractedType)->yes() + ) { + $arrayArg = $expr->right->left->getArgs()[0]->value; + $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject($dimFetch, $countArgType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), + ); } - - $result = $result->unionWith( - $typeSpecifier->create($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr), - ); } - } - // infer $list[$index] after $index < count($list) - if ( - $context->true() - && !$orEqual - // constant offsets are handled via HasOffsetType/HasOffsetValueType - && !$leftType instanceof ConstantIntegerType - && $argType->isList()->yes() - && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() - ) { - $arrayArg = $expr->right->getArgs()[0]->value; - $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); - $result = $result->unionWith( - $typeSpecifier->create($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), - ); - } - } + if ( + !$context->null() + && $expr->right instanceof Expr\FuncCall + && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() + && in_array(strtolower((string) $expr->right->name), ['preg_match'], true) + && count($expr->right->getArgs()) >= 3 + && ( + IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($leftType)->yes() + || ($expr instanceof BinaryOp\Smaller && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes()) + ) + ) { + // 0 < preg_match or 1 <= preg_match becomes 1 === preg_match + $newExpr = new BinaryOp\Identical($expr->right, new Scalar\Int_(1)); + + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, $newExpr, $context)->setRootExpr($expr); + } - // infer $list[$index] after $zeroOrMore < count($list) - N - // infer $list[$index] after $zeroOrMore <= count($list) - N - if ( - $context->true() - && $expr->right instanceof BinaryOp\Minus - && $expr->right->left instanceof Expr\FuncCall - && $expr->right->left->name instanceof Name - && !$expr->right->left->isFirstClassCallable() - && in_array(strtolower((string) $expr->right->left->name), ['count', 'sizeof'], true) - && count($expr->right->left->getArgs()) >= 1 - // constant offsets are handled via HasOffsetType/HasOffsetValueType - && !$leftType instanceof ConstantIntegerType - && $leftType->isInteger()->yes() - && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes() - ) { - $countArgType = $scope->getType($expr->right->left->getArgs()[0]->value); - $subtractedType = $scope->getType($expr->right->right); - if ( - $countArgType->isList()->yes() - && $typeSpecifier->isNormalCountCall($expr->right->left, $countArgType, $scope)->yes() - && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($subtractedType)->yes() - ) { - $arrayArg = $expr->right->left->getArgs()[0]->value; - $dimFetch = new Expr\ArrayDimFetch($arrayArg, $expr->left); - $result = $result->unionWith( - $typeSpecifier->create($dimFetch, $countArgType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr), - ); - } - } + if ( + !$context->null() + && $expr->right instanceof Expr\FuncCall + && $expr->right->name instanceof Name + && !$expr->right->isFirstClassCallable() + && in_array(strtolower((string) $expr->right->name), ['strlen', 'mb_strlen'], true) + && count($expr->right->getArgs()) === 1 + && $leftType->isInteger()->yes() + ) { + if ( + $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) + || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) + ) { + $argType = $getType($expr->right->getArgs()[0]->value); + if ($argType->isString()->yes()) { + $accessory = new AccessoryNonEmptyStringType(); + + if (IntegerRangeType::createAllGreaterThanOrEqualTo(2 - $offset)->isSuperTypeOf($leftType)->yes()) { + $accessory = new AccessoryNonFalsyStringType(); + } - if ( - !$context->null() - && $expr->right instanceof Expr\FuncCall - && $expr->right->name instanceof Name - && !$expr->right->isFirstClassCallable() - && in_array(strtolower((string) $expr->right->name), ['preg_match'], true) - && count($expr->right->getArgs()) >= 3 - && ( - IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($leftType)->yes() - || ($expr instanceof BinaryOp\Smaller && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes()) - ) - ) { - // 0 < preg_match or 1 <= preg_match becomes 1 === preg_match - $newExpr = new BinaryOp\Identical($expr->right, new Scalar\Int_(1)); - - return $typeSpecifier->specifyTypesInCondition($scope, $newExpr, $context)->setRootExpr($expr); - } + $result = $result->unionWith($this->defaultNarrowingHelper->createForSubject($expr->right->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr)); + } + } + } - if ( - !$context->null() - && $expr->right instanceof Expr\FuncCall - && $expr->right->name instanceof Name - && !$expr->right->isFirstClassCallable() - && in_array(strtolower((string) $expr->right->name), ['strlen', 'mb_strlen'], true) - && count($expr->right->getArgs()) === 1 - && $leftType->isInteger()->yes() - ) { - if ( - $context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes()) - || ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes()) - ) { - $argType = $scope->getType($expr->right->getArgs()[0]->value); - if ($argType->isString()->yes()) { - $accessory = new AccessoryNonEmptyStringType(); + if ($leftType instanceof ConstantIntegerType) { + if ($expr->right instanceof Expr\PostInc) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset + 1), + $context, + )); + } elseif ($expr->right instanceof Expr\PostDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset - 1), + $context, + )); + } elseif ($expr->right instanceof Expr\PreInc || $expr->right instanceof Expr\PreDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->right->var, + IntegerRangeType::fromInterval($leftType->getValue(), null, $offset), + $context, + )); + } + } - if (IntegerRangeType::createAllGreaterThanOrEqualTo(2 - $offset)->isSuperTypeOf($leftType)->yes()) { - $accessory = new AccessoryNonFalsyStringType(); + $rightType = $getType($expr->right); + if ($rightType instanceof ConstantIntegerType) { + if ($expr->left instanceof Expr\PostInc) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset + 1), + $context, + )); + } elseif ($expr->left instanceof Expr\PostDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset - 1), + $context, + )); + } elseif ($expr->left instanceof Expr\PreInc || $expr->left instanceof Expr\PreDec) { + $result = $result->unionWith($this->createRangeTypes( + $expr, + $expr->left->var, + IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset), + $context, + )); } + } - $result = $result->unionWith($typeSpecifier->create($expr->right->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr)); + if ($context->true()) { + if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject( + $expr->left, + $orEqual ? $rightType->getSmallerOrEqualType($this->phpVersion) : $rightType->getSmallerType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject( + $expr->right, + $orEqual ? $leftType->getGreaterOrEqualType($this->phpVersion) : $leftType->getGreaterType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + } elseif ($context->false()) { + if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject( + $expr->left, + $orEqual ? $rightType->getGreaterType($this->phpVersion) : $rightType->getGreaterOrEqualType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } + if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject( + $expr->right, + $orEqual ? $leftType->getSmallerType($this->phpVersion) : $leftType->getSmallerOrEqualType($this->phpVersion), + TypeSpecifierContext::createTruthy(), + $scope, + )->setRootExpr($expr), + ); + } } - } - } - if ($leftType instanceof ConstantIntegerType) { - if ($expr->right instanceof Expr\PostInc) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->right->var, - IntegerRangeType::fromInterval($leftType->getValue(), null, $offset + 1), - $context, - )); - } elseif ($expr->right instanceof Expr\PostDec) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->right->var, - IntegerRangeType::fromInterval($leftType->getValue(), null, $offset - 1), - $context, - )); - } elseif ($expr->right instanceof Expr\PreInc || $expr->right instanceof Expr\PreDec) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->right->var, - IntegerRangeType::fromInterval($leftType->getValue(), null, $offset), - $context, - )); + return $result; } - } - $rightType = $scope->getType($expr->right); - if ($rightType instanceof ConstantIntegerType) { - if ($expr->left instanceof Expr\PostInc) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->left->var, - IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset + 1), - $context, - )); - } elseif ($expr->left instanceof Expr\PostDec) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->left->var, - IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset - 1), - $context, - )); - } elseif ($expr->left instanceof Expr\PreInc || $expr->left instanceof Expr\PreDec) { - $result = $result->unionWith($this->createRangeTypes( - $expr, - $expr->left->var, - IntegerRangeType::fromInterval(null, $rightType->getValue(), -$offset), - $context, - )); + if ($expr instanceof BinaryOp\Greater) { + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new BinaryOp\Smaller($expr->right, $expr->left), $context)->setRootExpr($expr); } - } - if ($context->true()) { - if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { - $result = $result->unionWith( - $typeSpecifier->create( - $expr->left, - $orEqual ? $rightType->getSmallerOrEqualType($this->phpVersion) : $rightType->getSmallerType($this->phpVersion), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); + if ($expr instanceof BinaryOp\GreaterOrEqual) { + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new BinaryOp\SmallerOrEqual($expr->right, $expr->left), $context)->setRootExpr($expr); } - if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { - $result = $result->unionWith( - $typeSpecifier->create( - $expr->right, - $orEqual ? $leftType->getGreaterOrEqualType($this->phpVersion) : $leftType->getGreaterType($this->phpVersion), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); - } - } elseif ($context->false()) { - if (!$expr->left instanceof Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Scalar)) { - $result = $result->unionWith( - $typeSpecifier->create( - $expr->left, - $orEqual ? $rightType->getGreaterType($this->phpVersion) : $rightType->getGreaterOrEqualType($this->phpVersion), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); - } - if (!$expr->right instanceof Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Scalar)) { - $result = $result->unionWith( - $typeSpecifier->create( - $expr->right, - $orEqual ? $leftType->getSmallerType($this->phpVersion) : $leftType->getSmallerOrEqualType($this->phpVersion), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); - } - } - return $result; - } + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + }, + ); + } - if ($expr instanceof BinaryOp\Greater) { - return $typeSpecifier->specifyTypesInCondition($scope, new BinaryOp\Smaller($expr->right, $expr->left), $context)->setRootExpr($expr); + /** + * The boolean result of a `==` comparison, including the same-variable + * special case. Shared by the Equal and NotEqual type callbacks. + */ + private function resolveEqualType(MutatingScope $scope, BinaryOp\Equal $expr, ExpressionResult $leftResult, ExpressionResult $rightResult): Type + { + if ( + $expr->left instanceof Variable + && is_string($expr->left->name) + && $expr->right instanceof Variable + && is_string($expr->right->name) + && $expr->left->name === $expr->right->name + ) { + return new ConstantBooleanType(true); } - if ($expr instanceof BinaryOp\GreaterOrEqual) { - return $typeSpecifier->specifyTypesInCondition($scope, new BinaryOp\SmallerOrEqual($expr->right, $expr->left), $context)->setRootExpr($expr); - } + // the operands were processed during processExpr; use their results' types. + $leftType = $leftResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $rightType = $rightResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; } private function createRangeTypes(?Expr $rootExpr, Expr $expr, Type $type, TypeSpecifierContext $context): SpecifiedTypes diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index 6877fdd5b3e..1766fa2574b 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -16,14 +16,15 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; use PHPStan\Type\Type; @@ -37,6 +38,8 @@ final class CastHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, ) { } @@ -52,6 +55,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); + $subjectArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->expr, $storage); + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -60,45 +65,58 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $exprResult): Type { + if ($expr instanceof Cast\Unset_) { + return new NullType(); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr instanceof Cast\Unset_) { - return new NullType(); - } + return $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } - return $this->initializerExprTypeResolver->getCastType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } + throw new ShouldNotHappenException(); + }); + }, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $exprResult, $nodeScopeResolver, $beforeScope, $subjectArgResult): SpecifiedTypes { + $evaluationScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + // a cast's truthiness is a loose comparison of the inner + // expression - composed from its result; the fabricated + // literal is only printed into entries, never walked + if (($expr instanceof Cast\Bool_ || $expr instanceof Cast\Int_ || $expr instanceof Cast\Double) && !$context->null()) { + if ($expr instanceof Cast\Bool_) { + $literal = new ConstFetch(new FullyQualified('true')); + $equalContext = $context; + } elseif ($expr instanceof Cast\Int_) { + $literal = new Int_(0); + $equalContext = $context->negate(); + } else { + $literal = new Float_(0.0); + $equalContext = $context->negate(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($expr instanceof Cast\Bool_) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))), - $context, - )->setRootExpr($expr); - } - - if ($expr instanceof Cast\Int_) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new NotEqual($expr->expr, new Int_(0)), - $context, - )->setRootExpr($expr); - } - - if ($expr instanceof Cast\Double) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new NotEqual($expr->expr, new Float_(0.0)), - $context, - )->setRootExpr($expr); - } - - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + // the literal side never reads its stand-in result + $types = $this->identicalNarrowingHelper->specifyEqual($nodeScopeResolver, $expr->expr, $literal, $exprResult, $exprResult, $equalContext, $evaluationScope, $subjectArgResult, null); + if ($types !== null) { + return $types->setRootExpr($expr); + } + } + + if ($expr instanceof Cast\Bool_) { + return $evaluationScope->obtainResultForNode(new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))))->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr); + } + + if ($expr instanceof Cast\Int_) { + return $evaluationScope->obtainResultForNode(new NotEqual($expr->expr, new Int_(0)))->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr); + } + + if ($expr instanceof Cast\Double) { + return $evaluationScope->obtainResultForNode(new NotEqual($expr->expr, new Float_(0.0)))->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr); + } + + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + }, + ); } } diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php deleted file mode 100644 index 2ea2f176f8e..00000000000 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ /dev/null @@ -1,952 +0,0 @@ -findTypeExpressionsFromBinaryOperation($scope, $expr); - if ($expressions !== null) { - $exprNode = $expressions[0]; - $constantType = $expressions[1]; - $otherType = $expressions[2]; - - if (!$context->null() && $constantType->getValue() === null) { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantIntegerType(0), - new ConstantFloatType(0.0), - new ConstantStringType(''), - new ConstantArrayType([], []), - ]; - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); - } - - if (!$context->null() && $constantType->getValue() === false) { - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - $exprNode, - $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), - )->setRootExpr($expr); - } - - if (!$context->null() && $constantType->getValue() === true) { - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - $exprNode, - $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), - )->setRootExpr($expr); - } - - if (!$context->null() && $constantType->getValue() === 0 && !$otherType->isInteger()->yes() && !$otherType->isBoolean()->yes()) { - /* There is a difference between php 7.x and 8.x on the equality - * behavior between zero and the empty string, so to be conservative - * we leave it untouched regardless of the language version */ - if ($context->true()) { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantIntegerType(0), - new ConstantFloatType(0.0), - new StringType(), - ]; - } else { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantIntegerType(0), - new ConstantFloatType(0.0), - new ConstantStringType('0'), - ]; - } - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); - } - - if (!$context->null() && $constantType->getValue() === '') { - /* There is a difference between php 7.x and 8.x on the equality - * behavior between zero and the empty string, so to be conservative - * we leave it untouched regardless of the language version */ - if ($context->true()) { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantIntegerType(0), - new ConstantFloatType(0.0), - new ConstantStringType(''), - ]; - } else { - $trueTypes = [ - new NullType(), - new ConstantBooleanType(false), - new ConstantStringType(''), - ]; - } - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); - } - - if ( - $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && in_array(strtolower($exprNode->name->toString()), ['gettype', 'get_class', 'get_debug_type'], true) - && isset($exprNode->getArgs()[0]) - && $constantType->isString()->yes() - ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); - } - - if ( - $context->true() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && $exprNode->name->toLowerString() === 'preg_match' - && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes() - ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); - } - - if ( - $context->true() - && $exprNode instanceof ClassConstFetch - && $exprNode->name instanceof Node\Identifier - && strtolower($exprNode->name->toString()) === 'class' - && $constantType->isString()->yes() - ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); - } - } - - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); - - $leftBooleanType = $leftType->toBoolean(); - if ($leftBooleanType instanceof ConstantBooleanType && $rightType->isBoolean()->yes()) { - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BinaryOp\Identical( - new ConstFetch(new Name($leftBooleanType->getValue() ? 'true' : 'false')), - $expr->right, - ), - $context, - )->setRootExpr($expr); - } - - $rightBooleanType = $rightType->toBoolean(); - if ($rightBooleanType instanceof ConstantBooleanType && $leftType->isBoolean()->yes()) { - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BinaryOp\Identical( - $expr->left, - new ConstFetch(new Name($rightBooleanType->getValue() ? 'true' : 'false')), - ), - $context, - )->setRootExpr($expr); - } - - if ( - !$context->null() - && $rightType->isArray()->yes() - && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no() - ) { - return $this->typeSpecifier->create($expr->right, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); - } - - if ( - !$context->null() - && $leftType->isArray()->yes() - && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no() - ) { - return $this->typeSpecifier->create($expr->left, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); - } - - if ( - ($leftType->isString()->yes() && $rightType->isString()->yes()) - || ($leftType->isInteger()->yes() && $rightType->isInteger()->yes()) - || ($leftType->isFloat()->yes() && $rightType->isFloat()->yes()) - || ($leftType->isEnum()->yes() && $rightType->isEnum()->yes()) - ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); - } - - $leftExprString = $this->exprPrinter->printExpr($expr->left); - $rightExprString = $this->exprPrinter->printExpr($expr->right); - if ($leftExprString === $rightExprString) { - if (!$expr->left instanceof Expr\Variable || !$expr->right instanceof Expr\Variable) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - } - - $leftTypes = $this->typeSpecifier->create($expr->left, $leftType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->typeSpecifier->create($expr->right, $rightType, $context, $scope)->setRootExpr($expr); - - return $context->true() - ? $leftTypes->unionWith($rightTypes) - : $leftTypes->intersectWith($rightTypes); - } - - public function specifyTypesForIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes - { - $leftExpr = $expr->left; - $rightExpr = $expr->right; - - // Normalize to: fn() === expr - if ($rightExpr instanceof FuncCall && !$leftExpr instanceof FuncCall) { - $specifiedTypes = $this->specifyTypesForNormalizedIdentical(new Expr\BinaryOp\Identical( - $rightExpr, - $leftExpr, - ), $scope, $context); - } else { - $specifiedTypes = $this->specifyTypesForNormalizedIdentical(new Expr\BinaryOp\Identical( - $leftExpr, - $rightExpr, - ), $scope, $context); - } - - // merge result of fn1() === fn2() and fn2() === fn1() - if ($rightExpr instanceof FuncCall && $leftExpr instanceof FuncCall) { - return $specifiedTypes->unionWith( - $this->specifyTypesForNormalizedIdentical(new Expr\BinaryOp\Identical( - $rightExpr, - $leftExpr, - ), $scope, $context), - ); - } - - return $specifiedTypes; - } - - private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes - { - $leftExpr = $expr->left; - $rightExpr = $expr->right; - - $unwrappedLeftExpr = $leftExpr; - if ($leftExpr instanceof AlwaysRememberedExpr) { - $unwrappedLeftExpr = $leftExpr->getExpr(); - } - $unwrappedRightExpr = $rightExpr; - if ($rightExpr instanceof AlwaysRememberedExpr) { - $unwrappedRightExpr = $rightExpr->getExpr(); - } - - $rightType = $scope->getType($rightExpr); - - // (count($a) === $expr) - if ( - !$context->null() - && $unwrappedLeftExpr instanceof FuncCall - && !$unwrappedLeftExpr->isFirstClassCallable() - && count($unwrappedLeftExpr->getArgs()) >= 1 - && $unwrappedLeftExpr->name instanceof Name - && in_array(strtolower((string) $unwrappedLeftExpr->name), ['count', 'sizeof'], true) - && $rightType->isInteger()->yes() - ) { - // count($a) === count($b) - if ( - $context->true() - && $unwrappedRightExpr instanceof FuncCall - && $unwrappedRightExpr->name instanceof Name - && !$unwrappedRightExpr->isFirstClassCallable() - && in_array($unwrappedRightExpr->name->toLowerString(), ['count', 'sizeof'], true) - && count($unwrappedRightExpr->getArgs()) >= 1 - ) { - $argType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); - $sizeType = $scope->getType($leftExpr); - - $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($unwrappedRightExpr, $argType, $sizeType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - return $specifiedTypes; - } - - $leftArrayType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); - $rightArrayType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); - if ( - $leftArrayType->isArray()->yes() - && $rightArrayType->isArray()->yes() - && !$rightType->isConstantScalarValue()->yes() - && ($leftArrayType->isIterableAtLeastOnce()->yes() || $rightArrayType->isIterableAtLeastOnce()->yes()) - ) { - $arrayTypes = $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr); - return $arrayTypes->unionWith( - $this->typeSpecifier->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), - ); - } - } - - if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); - } - - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); - $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); - if ($isZero->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - - if ($context->truthy() && !$argType->isArray()->yes()) { - $newArgType = new UnionType([ - new ObjectType(Countable::class), - new ConstantArrayType([], []), - ]); - } else { - $newArgType = new ConstantArrayType([], []); - } - - return $funcTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope)->setRootExpr($expr), - ); - } - - $specifiedTypes = $this->typeSpecifier->specifyTypesForCountFuncCall($unwrappedLeftExpr, $argType, $rightType, $context, $scope, $expr); - if ($specifiedTypes !== null) { - if ($leftExpr !== $unwrappedLeftExpr) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - return $specifiedTypes->unionWith($funcTypes); - } - return $specifiedTypes; - } - - if ($context->truthy() && $argType->isArray()->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { - return $funcTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), - ); - } - - return $funcTypes; - } - } - - // strlen($a) === $b - if ( - !$context->null() - && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && in_array(strtolower((string) $unwrappedLeftExpr->name), ['strlen', 'mb_strlen'], true) - && count($unwrappedLeftExpr->getArgs()) === 1 - && $rightType->isInteger()->yes() - ) { - if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); - } - - $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); - if ($isZero->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - return $funcTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope)->setRootExpr($expr), - ); - } - - if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); - if ($argType->isString()->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - - $accessory = new AccessoryNonEmptyStringType(); - if (IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($rightType)->yes()) { - $accessory = new AccessoryNonFalsyStringType(); - } - $valueTypes = $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope)->setRootExpr($expr); - - return $funcTypes->unionWith($valueTypes); - } - } - } - - // array_key_first($a) !== null - // array_key_last($a) !== null - // array_find_key($a, $cb) !== null - if ( - $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && isset($unwrappedLeftExpr->getArgs()[0]) - && $rightType->isNull()->yes() - ) { - $funcName = $unwrappedLeftExpr->name->toLowerString(); - $bothDirections = in_array($funcName, ['array_key_first', 'array_key_last'], true); - $notNullOnly = $funcName === 'array_find_key'; - if ($bothDirections || $notNullOnly) { - $args = $unwrappedLeftExpr->getArgs(); - $argType = $scope->getType($args[0]->value); - if ($argType->isArray()->yes()) { - if ($bothDirections) { - return $this->typeSpecifier->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); - } - if ($context->falsey()) { - return $this->typeSpecifier->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); - } - } - } - } - - // preg_match($a) === $b - if ( - $context->true() - && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' - && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() - ) { - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - $leftExpr, - $context, - )->setRootExpr($expr); - } - - // get_class($a) === 'Foo' - if ( - $context->true() - && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && in_array(strtolower($unwrappedLeftExpr->name->toString()), ['get_class', 'get_debug_type'], true) - && isset($unwrappedLeftExpr->getArgs()[0]) - ) { - $constantStringTypes = $rightType->getConstantStrings(); - if (count($constantStringTypes) === 1 && $this->reflectionProvider->hasClass($constantStringTypes[0]->getValue())) { - return $this->typeSpecifier->create( - $unwrappedLeftExpr->getArgs()[0]->value, - new ObjectType($constantStringTypes[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStringTypes[0]->getValue())->asFinal()), - $context, - $scope, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); - } - if ($rightType->getClassStringObjectType()->isObject()->yes()) { - return $this->typeSpecifier->create( - $unwrappedLeftExpr->getArgs()[0]->value, - $rightType->getClassStringObjectType(), - $context, - $scope, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); - } - } - - if ( - $context->truthy() - && $unwrappedLeftExpr instanceof FuncCall - && $unwrappedLeftExpr->name instanceof Name - && !$unwrappedLeftExpr->isFirstClassCallable() - && in_array(strtolower($unwrappedLeftExpr->name->toString()), [ - 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', - 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', - 'ucwords', 'mb_convert_case', 'mb_convert_kana', - ], true) - && isset($unwrappedLeftExpr->getArgs()[0]) - && $rightType->isNonEmptyString()->yes() - ) { - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); - - if ($argType->isString()->yes()) { - $specifiedTypes = new SpecifiedTypes(); - if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtolower', 'mb_strtolower'], true)) { - $specifiedTypes = $this->typeSpecifier->create( - $unwrappedRightExpr, - TypeCombinator::intersect($rightType, new AccessoryLowercaseStringType()), - $context, - $scope, - )->setRootExpr($expr); - } - if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { - $specifiedTypes = $this->typeSpecifier->create( - $unwrappedRightExpr, - TypeCombinator::intersect($rightType, new AccessoryUppercaseStringType()), - $context, - $scope, - )->setRootExpr($expr); - } - - if ($rightType->isNonFalsyString()->yes()) { - return $specifiedTypes->unionWith($this->typeSpecifier->create( - $unwrappedLeftExpr->getArgs()[0]->value, - TypeCombinator::intersect($argType, new AccessoryNonFalsyStringType()), - $context, - $scope, - )->setRootExpr($expr)); - } - - return $specifiedTypes->unionWith($this->typeSpecifier->create( - $unwrappedLeftExpr->getArgs()[0]->value, - TypeCombinator::intersect($argType, new AccessoryNonEmptyStringType()), - $context, - $scope, - )->setRootExpr($expr)); - } - } - - if ($rightType->isString()->yes()) { - $types = null; - foreach ($rightType->getConstantStrings() as $constantString) { - $specifiedType = $this->specifyTypesForConstantStringBinaryExpression($unwrappedLeftExpr, $constantString, $context, $scope, $expr); - - if ($specifiedType === null) { - continue; - } - if ($types === null) { - $types = $specifiedType; - continue; - } - - $types = $types->intersectWith($specifiedType); - } - - if ($types !== null) { - if ($leftExpr !== $unwrappedLeftExpr) { - $types = $types->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); - } - return $types; - } - } - - $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); - if ($expressions !== null) { - $exprNode = $expressions[0]; - $constantType = $expressions[1]; - - $unwrappedExprNode = $exprNode; - if ($exprNode instanceof AlwaysRememberedExpr) { - $unwrappedExprNode = $exprNode->getExpr(); - } - - $specifiedType = $this->specifyTypesForConstantBinaryExpression($unwrappedExprNode, $constantType, $context, $scope, $expr); - if ($specifiedType !== null) { - if ($exprNode !== $unwrappedExprNode) { - $specifiedType = $specifiedType->unionWith( - $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($expr), - ); - } - return $specifiedType; - } - } - - // $a::class === 'Foo' - if ( - $context->true() && - $unwrappedLeftExpr instanceof ClassConstFetch && - $unwrappedLeftExpr->class instanceof Expr && - $unwrappedLeftExpr->name instanceof Node\Identifier && - $unwrappedRightExpr instanceof ClassConstFetch && - strtolower($unwrappedLeftExpr->name->toString()) === 'class' - ) { - $constantStrings = $rightType->getConstantStrings(); - if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { - if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->typeSpecifier->create( - $unwrappedLeftExpr->class, - new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), - $context, - $scope, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); - } - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - new Instanceof_( - $unwrappedLeftExpr->class, - new Name($constantStrings[0]->getValue()), - ), - $context, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); - } - } - - $leftType = $scope->getType($leftExpr); - - // 'Foo' === $a::class - if ( - $context->true() && - $unwrappedRightExpr instanceof ClassConstFetch && - $unwrappedRightExpr->class instanceof Expr && - $unwrappedRightExpr->name instanceof Node\Identifier && - $unwrappedLeftExpr instanceof ClassConstFetch && - strtolower($unwrappedRightExpr->name->toString()) === 'class' - ) { - $constantStrings = $leftType->getConstantStrings(); - if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { - if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->typeSpecifier->create( - $unwrappedRightExpr->class, - new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), - $context, - $scope, - )->unionWith($this->typeSpecifier->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); - } - - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - new Instanceof_( - $unwrappedRightExpr->class, - new Name($constantStrings[0]->getValue()), - ), - $context, - )->unionWith($this->typeSpecifier->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); - } - } - - if ($context->false()) { - $identicalType = $scope->getType($expr); - if ($identicalType instanceof ConstantBooleanType) { - $never = new NeverType(); - $contextForTypes = $identicalType->getValue() ? $context->negate() : $context; - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $this->typeSpecifier->create($unwrappedLeftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); - } else { - $leftTypes = $this->typeSpecifier->create($leftExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); - } - if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightTypes = $this->typeSpecifier->create($unwrappedRightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); - } else { - $rightTypes = $this->typeSpecifier->create($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); - } - return $leftTypes->unionWith($rightTypes); - } - } - - $types = null; - if ( - count($leftType->getFiniteTypes()) === 1 - || ( - $context->true() - && $leftType->isConstantValue()->yes() - && !$rightType->equals($leftType) - && $rightType->isSuperTypeOf($leftType)->yes()) - ) { - $types = $this->typeSpecifier->create( - $rightExpr, - $leftType, - $context, - $scope, - )->setRootExpr($expr); - if ($rightExpr instanceof AlwaysRememberedExpr) { - $types = $types->unionWith($this->typeSpecifier->create( - $unwrappedRightExpr, - $leftType, - $context, - $scope, - ))->setRootExpr($expr); - } - } - if ( - count($rightType->getFiniteTypes()) === 1 - || ( - $context->true() - && $rightType->isConstantValue()->yes() - && !$leftType->equals($rightType) - && $leftType->isSuperTypeOf($rightType)->yes() - ) - ) { - $leftTypes = $this->typeSpecifier->create( - $leftExpr, - $rightType, - $context, - $scope, - )->setRootExpr($expr); - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $leftTypes->unionWith($this->typeSpecifier->create( - $unwrappedLeftExpr, - $rightType, - $context, - $scope, - ))->setRootExpr($expr); - } - if ($types !== null) { - $types = $types->unionWith($leftTypes); - } else { - $types = $leftTypes; - } - } - - if ($types !== null) { - return $types; - } - - $leftExprString = $this->exprPrinter->printExpr($unwrappedLeftExpr); - $rightExprString = $this->exprPrinter->printExpr($unwrappedRightExpr); - if ($leftExprString === $rightExprString) { - if (!$unwrappedLeftExpr instanceof Expr\Variable || !$unwrappedRightExpr instanceof Expr\Variable) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - } - - if ($context->true()) { - $leftTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->typeSpecifier->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr); - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftTypes = $leftTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr, $rightType, $context, $scope)->setRootExpr($expr), - ); - } - if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightTypes = $rightTypes->unionWith( - $this->typeSpecifier->create($unwrappedRightExpr, $leftType, $context, $scope)->setRootExpr($expr), - ); - } - return $leftTypes->unionWith($rightTypes); - } elseif ($context->false()) { - return $this->typeSpecifier->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr) - ->intersectWith($this->typeSpecifier->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)); - } - - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - - /** - * @return array{Expr, ConstantScalarType, Type}|null - */ - private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array - { - $leftType = $scope->getType($binaryOperation->left); - $rightType = $scope->getType($binaryOperation->right); - - $rightExpr = $binaryOperation->right; - if ($rightExpr instanceof AlwaysRememberedExpr) { - $rightExpr = $rightExpr->getExpr(); - } - - $leftExpr = $binaryOperation->left; - if ($leftExpr instanceof AlwaysRememberedExpr) { - $leftExpr = $leftExpr->getExpr(); - } - - if ( - $leftType instanceof ConstantScalarType - && !$rightExpr instanceof ConstFetch - ) { - return [$binaryOperation->right, $leftType, $rightType]; - } elseif ( - $rightType instanceof ConstantScalarType - && !$leftExpr instanceof ConstFetch - ) { - return [$binaryOperation->left, $rightType, $leftType]; - } - - return null; - } - - private function specifyTypesForConstantBinaryExpression( - Expr $exprNode, - Type $constantType, - TypeSpecifierContext $context, - Scope $scope, - Expr $rootExpr, - ): ?SpecifiedTypes - { - if (!$context->null() && $constantType->isFalse()->yes()) { - $types = $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { - return $types; - } - - return $types->unionWith($this->typeSpecifier->specifyTypesInCondition( - $scope, - $exprNode, - $context->true() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createFalse()->negate(), - )->setRootExpr($rootExpr)); - } - - if (!$context->null() && $constantType->isTrue()->yes()) { - $types = $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { - return $types; - } - - return $types->unionWith($this->typeSpecifier->specifyTypesInCondition( - $scope, - $exprNode, - $context->true() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTrue()->negate(), - )->setRootExpr($rootExpr)); - } - - return null; - } - - private function specifyTypesForConstantStringBinaryExpression( - Expr $exprNode, - Type $constantType, - TypeSpecifierContext $context, - Scope $scope, - Expr $rootExpr, - ): ?SpecifiedTypes - { - $scalarValues = $constantType->getConstantScalarValues(); - if (count($scalarValues) !== 1 || !is_string($scalarValues[0])) { - return null; - } - $constantStringValue = $scalarValues[0]; - - if ( - $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && strtolower($exprNode->name->toString()) === 'gettype' - && isset($exprNode->getArgs()[0]) - ) { - $type = null; - if ($constantStringValue === 'string') { - $type = new StringType(); - } - if ($constantStringValue === 'array') { - $type = new ArrayType(new MixedType(), new MixedType()); - } - if ($constantStringValue === 'boolean') { - $type = new BooleanType(); - } - if (in_array($constantStringValue, ['resource', 'resource (closed)'], true)) { - $type = new ResourceType(); - } - if ($constantStringValue === 'integer') { - $type = new IntegerType(); - } - if ($constantStringValue === 'double') { - $type = new FloatType(); - } - if ($constantStringValue === 'NULL') { - $type = new NullType(); - } - if ($constantStringValue === 'object') { - $type = new ObjectWithoutClassType(); - } - - if ($type !== null) { - $callType = $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); - $argType = $this->typeSpecifier->create($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); - return $callType->unionWith($argType); - } - } - - if ( - $context->true() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && strtolower((string) $exprNode->name) === 'get_parent_class' - && isset($exprNode->getArgs()[0]) - ) { - $argType = $scope->getType($exprNode->getArgs()[0]->value); - $objectType = new ObjectType($constantStringValue); - $classStringType = new GenericClassStringType($objectType); - - if ($argType->isString()->yes()) { - return $this->typeSpecifier->create( - $exprNode->getArgs()[0]->value, - $classStringType, - $context, - $scope, - )->setRootExpr($rootExpr); - } - - if ($argType->isObject()->yes()) { - return $this->typeSpecifier->create( - $exprNode->getArgs()[0]->value, - $objectType, - $context, - $scope, - )->setRootExpr($rootExpr); - } - - return $this->typeSpecifier->create( - $exprNode->getArgs()[0]->value, - TypeCombinator::union($objectType, $classStringType), - $context, - $scope, - )->setRootExpr($rootExpr); - } - - if ( - $context->false() - && $exprNode instanceof FuncCall - && $exprNode->name instanceof Name - && !$exprNode->isFirstClassCallable() - && in_array(strtolower((string) $exprNode->name), [ - 'trim', 'ltrim', 'rtrim', 'chop', - 'mb_trim', 'mb_ltrim', 'mb_rtrim', - ], true) - && isset($exprNode->getArgs()[0]) - && $constantStringValue === '' - ) { - $argValue = $exprNode->getArgs()[0]->value; - $argType = $scope->getType($argValue); - if ($argType->isString()->yes()) { - return $this->typeSpecifier->create( - $argValue, - new IntersectionType([ - new StringType(), - new AccessoryNonEmptyStringType(), - ]), - $context->negate(), - $scope, - )->setRootExpr($rootExpr); - } - } - - return null; - } - -} diff --git a/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php new file mode 100644 index 00000000000..470520c8202 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -0,0 +1,1183 @@ +null()) { + return null; + } + + // slices 1+2 cover comparisons against a null/true/false literal; + // everything else falls through to the scalar-literal slice below + if ($left instanceof Expr\ConstFetch && in_array($left->name->toLowerString(), ['null', 'true', 'false'], true)) { + $constantName = $left->name->toLowerString(); + $subject = $right; + $subjectResult = $rightResult; + } elseif ($right instanceof Expr\ConstFetch && in_array($right->name->toLowerString(), ['null', 'true', 'false'], true)) { + $constantName = $right->name->toLowerString(); + $subject = $left; + $subjectResult = $leftResult; + } else { + // a side whose TYPE is a constant bool (match (true) arms, bool + // class constants) compares like the literal - the old + // constant-binary handling, composed + $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; + $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; + $leftType = $this->literalType($unwrappedLeft) ?? $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (($leftType->isTrue()->yes() || $leftType->isFalse()->yes()) && !$unwrappedRight instanceof Expr\ConstFetch) { + return $this->specifyAgainstBool($right, $rightResult, $leftType->isTrue()->yes(), $context, $evaluationScope); + } + $rightType = $this->literalType($unwrappedRight) ?? $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (($rightType->isTrue()->yes() || $rightType->isFalse()->yes()) && !$unwrappedLeft instanceof Expr\ConstFetch) { + return $this->specifyAgainstBool($left, $leftResult, $rightType->isTrue()->yes(), $context, $evaluationScope); + } + + $types = $this->specifyAgainstScalarLiteral($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + if ($types !== null) { + return $types; + } + + return $this->specifyGeneral($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + } + + if ($constantName === 'null') { + // deliberately NOT guarded by specifyDecidedComparison(): a decided + // null comparison still emits its subtraction entry so assign-time + // conditional holders fire ($id = $x?->prop; if ($id !== null) makes + // $x non-null even when $id's own type already excludes null) - the + // old path's blanket guard here is what kept bug-10482 red + return $this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $subject, + $subjectResult, + new NullType(), + $context, + ); + } + + return $this->specifyAgainstBool($subject, $subjectResult, $constantName === 'true', $context, $evaluationScope); + } + + /** + * A bool constant pins itself through the entries and runs the subject's + * own narrowing in the matching bool context - identity, not truthiness: + * `=== false` is the false context, not falsey. + */ + private function specifyAgainstBool( + Expr $subject, + ExpressionResult $subjectResult, + bool $value, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ): SpecifiedTypes + { + $types = $this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $subject, + $subjectResult, + new ConstantBooleanType($value), + $context, + ); + + // a nullsafe chain that did not produce the constant may have + // short-circuited instead - its own narrowing only holds when the + // comparison succeeded + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + if (!$context->true() && ($unwrappedSubject instanceof Expr\NullsafeMethodCall || $unwrappedSubject instanceof Expr\NullsafePropertyFetch)) { + return $types; + } + + $boolContext = $value ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + + return $types->unionWith($subjectResult->getSpecifiedTypesForScope( + $evaluationScope, + $context->true() ? $boolContext : $boolContext->negate(), + )); + } + + /** + * Slice 3: comparisons against a scalar literal or a class constant + * (`$a === 5`, `$s === Foo::BAR`, `$suit === Suit::Hearts`) pin the + * single-valued side onto the other operand - the composed form of the + * finite-types narrowing at the tail of the old identical path. + */ + /** + * @param callable(): Type $identicalTypeCallback + */ + private function specifyAgainstScalarLiteral( + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + if ($this->isScalarLiteral($left)) { + $constantExpr = $left; + $constantResult = $leftResult; + $subject = $right; + $subjectResult = $rightResult; + } elseif ($this->isScalarLiteral($right)) { + $constantExpr = $right; + $constantResult = $rightResult; + $subject = $left; + $subjectResult = $leftResult; + } else { + return null; + } + + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + if ($unwrappedSubject instanceof Expr\FuncCall) { + $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $this->literalType($constantExpr) ?? $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted), $context, $evaluationScope, $subject === $left ? $leftArgResult : $rightArgResult); + if ($familyTypes === null) { + return null; + } + if ($familyTypes !== false) { + return $familyTypes; + } + } elseif ($unwrappedSubject instanceof Expr\ClassConstFetch && $unwrappedSubject->class instanceof Expr) { + // only ::class composes; a constant fetched off an object falls back + if ($unwrappedSubject->name instanceof Expr || $unwrappedSubject->name->toLowerString() !== 'class') { + return null; + } + } elseif (!$this->isSubjectCoveredAgainstConstant($subject)) { + return null; + } + + $constantType = $this->literalType($constantExpr) ?? $constantResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (count($constantType->getFiniteTypes()) !== 1) { + // a class constant does not have to be single-valued + return null; + } + + // $a::class === Foo::class narrows $a to a final Foo when true; + // other contexts and plain-string sides only pin the fetch + if ( + $unwrappedSubject instanceof Expr\ClassConstFetch + && $unwrappedSubject->class instanceof Expr + && $context->true() + && $constantExpr instanceof Expr\ClassConstFetch + ) { + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { + if (!$this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { + // an unknown class name narrows like instanceof - not composed yet + return null; + } + + return $this->defaultNarrowingHelper->createForSubject( + $unwrappedSubject->class, + new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()), + $context, + $evaluationScope, + )->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)); + } + } + + $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + if ($decidedTypes !== null) { + return $decidedTypes; + } + + $types = $this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $subject, + $subjectResult, + $constantType, + $context, + ); + + // a single-valued subject pins its value onto the literal side too + $subjectType = $subjectResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (count($subjectType->getFiniteTypes()) === 1) { + $types = $types->unionWith($this->defaultNarrowingHelper->createSubjectTypes( + $evaluationScope, + $constantExpr, + $constantResult, + $subjectType, + $context, + )); + } + + return $types; + } + + /** + * A statically decided comparison tells the false context nothing: the + * branch is dead on the certain flavour, and subtracting the constant + * would wrongly leak into the wider native flavour (mixed~'ab'). The + * NeverType entries mirror the old identical tail's no-op. + * + * @param callable(): Type $identicalTypeCallback + */ + private function specifyDecidedComparison( + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + if (!$context->false()) { + return null; + } + + $identicalType = $identicalTypeCallback(); + $isTrue = $identicalType->isTrue()->yes(); + if (!$isTrue && !$identicalType->isFalse()->yes()) { + return null; + } + + $never = new NeverType(); + $contextForTypes = $isTrue ? $context->negate() : $context; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $never, $contextForTypes)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $never, $contextForTypes), + ); + } + + private function getTypeFromGettypeStringValue(string $value): ?Type + { + if ($value === 'string') { + return new StringType(); + } + if ($value === 'array') { + return new ArrayType(new MixedType(), new MixedType()); + } + if ($value === 'boolean') { + return new BooleanType(); + } + if (in_array($value, ['resource', 'resource (closed)'], true)) { + return new ResourceType(); + } + if ($value === 'integer') { + return new IntegerType(); + } + if ($value === 'double') { + return new FloatType(); + } + if ($value === 'NULL') { + return new NullType(); + } + if ($value === 'object') { + return new ObjectWithoutClassType(); + } + + return null; + } + + /** + * The general expr-vs-expr tail of the identical narrowing: a + * single-valued side pins its value onto the other, otherwise both sides + * pin each other's types in the true context and cross-exclude in the + * false one. Runs only for operand shapes whose specialized narrowing is + * already composed - calls and ::class fetches still fall back. + * + * @param callable(): Type $identicalTypeCallback + */ + private function specifyGeneral( + NodeScopeResolver $nodeScopeResolver, + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; + $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; + + // a `$a::class` side falls back only where the old instanceof-style + // blocks would fire: a true context with a single class-name string + // on the other side; everything else narrows generically + if ($context->true()) { + foreach ([ + [$unwrappedLeft, $left, $leftResult, $rightResult], + [$unwrappedRight, $right, $rightResult, $leftResult], + ] as [$sideUnwrapped, $side, $sideResult, $otherResult]) { + if (!($sideUnwrapped instanceof Expr\ClassConstFetch) || !($sideUnwrapped->class instanceof Expr)) { + continue; + } + // only `$expr::class` names the fetched-on class - any other + // constant ($obj::TYPE === '') compares plain + // values and must not narrow the fetched-on object + if ($sideUnwrapped->name instanceof Expr || $sideUnwrapped->name->toLowerString() !== 'class') { + continue; + } + $otherStrings = $otherResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->getConstantStrings(); + if (count($otherStrings) !== 1 || $otherStrings[0]->getValue() === '') { + continue; + } + if (!$this->reflectionProvider->hasClass($otherStrings[0]->getValue())) { + // an unknown class narrows like instanceof: intersect the + // fetched-on object with the named type (it cannot be pinned + // as final without reflection) + return $this->defaultNarrowingHelper->createForSubject( + $sideUnwrapped->class, + new ObjectType($otherStrings[0]->getValue()), + $context, + $evaluationScope, + )->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $side, $sideResult, $otherStrings[0], $context)); + } + + return $this->defaultNarrowingHelper->createForSubject( + $sideUnwrapped->class, + new ObjectType($otherStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($otherStrings[0]->getValue())->asFinal()), + $context, + $evaluationScope, + )->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $side, $sideResult, $otherStrings[0], $context)); + } + } + + $leftType = $this->literalType($unwrappedLeft) ?? $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $rightType = $this->literalType($unwrappedRight) ?? $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + // fn1() === fn2() merges both normalized directions + if ($unwrappedLeft instanceof Expr\FuncCall && $unwrappedRight instanceof Expr\FuncCall) { + // count($a) === count($b): a decided size flows across; otherwise + // one non-empty side makes both non-empty + if ( + $context->true() + && $unwrappedLeft->name instanceof Name && in_array($unwrappedLeft->name->toLowerString(), ['count', 'sizeof'], true) && !$unwrappedLeft->isFirstClassCallable() && isset($unwrappedLeft->getArgs()[0]) + && $unwrappedRight->name instanceof Name && in_array($unwrappedRight->name->toLowerString(), ['count', 'sizeof'], true) && !$unwrappedRight->isFirstClassCallable() && isset($unwrappedRight->getArgs()[0]) + ) { + if ($leftArgResult === null || $rightArgResult === null) { + return null; + } + $rightArgType = $rightArgResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $countTypes = $this->countNarrowingHelper->specifyCountSize($unwrappedRight, $rightArgType, $leftType, $context, $evaluationScope, $unwrappedRight); + if ($countTypes !== null) { + return $countTypes; + } + + $leftArgType = $leftArgResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if ( + $leftArgType->isArray()->yes() + && $rightArgType->isArray()->yes() + && !$rightType->isConstantScalarValue()->yes() + && ($leftArgType->isIterableAtLeastOnce()->yes() || $rightArgType->isIterableAtLeastOnce()->yes()) + ) { + return $this->defaultNarrowingHelper->createForSubject($unwrappedLeft->getArgs()[0]->value, new NonEmptyArrayType(), $context, $evaluationScope)->unionWith( + $this->defaultNarrowingHelper->createForSubject($unwrappedRight->getArgs()[0]->value, new NonEmptyArrayType(), $context, $evaluationScope), + ); + } + } + + $leftDirection = $this->specifyFuncCallFamilies($left, $leftResult, $unwrappedLeft, $right, $rightType, $context, $evaluationScope, $leftArgResult); + $rightDirection = $this->specifyFuncCallFamilies($right, $rightResult, $unwrappedRight, $left, $leftType, $context, $evaluationScope, $rightArgResult); + if ($leftDirection === null || $rightDirection === null) { + return null; + } + $merged = null; + if ($leftDirection !== false) { + $merged = $leftDirection; + } + if ($rightDirection !== false) { + $merged = $merged !== null ? $merged->unionWith($rightDirection) : $rightDirection; + } + if ($merged !== null) { + return $merged; + } + + // neither family matched - the generic tail below pins both sides + } + + // a single call side runs the family compositions with the other + // side's TYPE as the constant - the composed form of the old + // normalization that moved the call to the left + if ($unwrappedLeft instanceof Expr\FuncCall || $unwrappedRight instanceof Expr\FuncCall) { + if ($unwrappedLeft instanceof Expr\FuncCall) { + $familyTypes = $this->specifyFuncCallFamilies($left, $leftResult, $unwrappedLeft, $right, $rightType, $context, $evaluationScope, $leftArgResult); + } else { + $familyTypes = $this->specifyFuncCallFamilies($right, $rightResult, $unwrappedRight, $left, $leftType, $context, $evaluationScope, $rightArgResult); + } + if ($familyTypes === null) { + return null; + } + if ($familyTypes !== false) { + return $familyTypes; + } + } + + $decidedTypes = $this->specifyDecidedComparison($left, $right, $leftResult, $rightResult, $context, $evaluationScope, $identicalTypeCallback); + if ($decidedTypes !== null) { + return $decidedTypes; + } + + $types = null; + if ( + count($leftType->getFiniteTypes()) === 1 + || ( + $context->true() + && $leftType->isConstantValue()->yes() + && !$rightType->equals($leftType) + && $rightType->isSuperTypeOf($leftType)->yes()) + ) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context); + } + if ( + count($rightType->getFiniteTypes()) === 1 + || ( + $context->true() + && $rightType->isConstantValue()->yes() + && !$leftType->equals($rightType) + && $leftType->isSuperTypeOf($rightType)->yes() + ) + ) { + $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $rightType, $context); + $types = $types !== null ? $types->unionWith($leftTypes) : $leftTypes; + } + + if ($types !== null) { + return $types; + } + + $leftExprString = $this->exprPrinter->printExpr($unwrappedLeft); + $rightExprString = $this->exprPrinter->printExpr($unwrappedRight); + if ($leftExprString === $rightExprString) { + if (!$unwrappedLeft instanceof Expr\Variable || !$unwrappedRight instanceof Expr\Variable) { + return new SpecifiedTypes([], []); + } + } + + if ($context->true()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $rightType, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $leftType, $context), + ); + } elseif ($context->false()) { + return $this->defaultNarrowingHelper->toSureTypes($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $leftType, $context), $evaluationScope) + ->intersectWith($this->defaultNarrowingHelper->toSureTypes($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $rightType, $context), $evaluationScope)); + } + + return new SpecifiedTypes([], []); + } + + /** + * New-world narrowing for `==` (and, via a negated context, `!=`): + * loose comparisons reduce to falsy-set pins, truthiness delegation, or + * the identical narrowing when coercion cannot differ - all composed + * from the operand results, no synthetic nodes. Uncovered shapes return + * null and fall back to the old-world Equal path. + */ + public function specifyEqual( + NodeScopeResolver $nodeScopeResolver, + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, + ): ?SpecifiedTypes + { + if ($context->null()) { + return null; + } + + $identicalTypeCallback = fn (): Type => $this->richerScopeGetTypeHelper->getIdenticalResult($evaluationScope, new Expr\BinaryOp\Identical($left, $right), $nodeScopeResolver)->type; + + $unwrappedLeft = $left instanceof AlwaysRememberedExpr ? $left->getExpr() : $left; + $unwrappedRight = $right instanceof AlwaysRememberedExpr ? $right->getExpr() : $right; + $leftType = $this->literalType($unwrappedLeft) ?? $leftResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $rightType = $this->literalType($unwrappedRight) ?? $rightResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + $leftScalarValues = $leftType->getConstantScalarValues(); + $rightScalarValues = $rightType->getConstantScalarValues(); + if (count($leftScalarValues) === 1 && !$unwrappedRight instanceof Expr\ConstFetch) { + $constantSideTypes = $this->specifyEqualAgainstConstantSide($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $right, $rightResult, $leftScalarValues[0], $leftType, $rightType, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + if ($constantSideTypes !== false) { + return $constantSideTypes; + } + } elseif (count($rightScalarValues) === 1 && !$unwrappedLeft instanceof Expr\ConstFetch) { + $constantSideTypes = $this->specifyEqualAgainstConstantSide($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $left, $leftResult, $rightScalarValues[0], $rightType, $leftType, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + if ($constantSideTypes !== false) { + return $constantSideTypes; + } + } + + // a side that coerces to a known bool compares the other side's + // truthiness - the literal-bool identical narrowing composes it + $leftBool = $leftType->toBoolean(); + if (($leftBool->isTrue()->yes() || $leftBool->isFalse()->yes()) && $rightType->isBoolean()->yes()) { + // the literal side of the delegation needs no result; the subject side is the right operand + return $this->specifyIdentical($nodeScopeResolver, new Expr\ConstFetch(new Name($leftBool->isTrue()->yes() ? 'true' : 'false')), $right, $rightResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + } + $rightBool = $rightType->toBoolean(); + if (($rightBool->isTrue()->yes() || $rightBool->isFalse()->yes()) && $leftType->isBoolean()->yes()) { + return $this->specifyIdentical($nodeScopeResolver, $left, new Expr\ConstFetch(new Name($rightBool->isTrue()->yes() ? 'true' : 'false')), $leftResult, $leftResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + } + + // an empty constant array equals only empty countables + if ($rightType->isArray()->yes() && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, new NonEmptyArrayType(), $context->negate()); + } + if ($leftType->isArray()->yes() && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, new NonEmptyArrayType(), $context->negate()); + } + + // same-type sides cannot coerce - loose equals strict + if ( + ($leftType->isString()->yes() && $rightType->isString()->yes()) + || ($leftType->isInteger()->yes() && $rightType->isInteger()->yes()) + || ($leftType->isFloat()->yes() && $rightType->isFloat()->yes()) + || ($leftType->isEnum()->yes() && $rightType->isEnum()->yes()) + ) { + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + } + + $leftExprString = $this->exprPrinter->printExpr($left); + $rightExprString = $this->exprPrinter->printExpr($right); + if ($leftExprString === $rightExprString) { + if (!$left instanceof Expr\Variable || !$right instanceof Expr\Variable) { + return new SpecifiedTypes([], []); + } + } + + $leftTypes = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $left, $leftResult, $leftType, $context); + $rightTypes = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $right, $rightResult, $rightType, $context); + + return $context->true() + ? $leftTypes->unionWith($rightTypes) + : $this->defaultNarrowingHelper->toSureTypes($leftTypes, $evaluationScope)->intersectWith($this->defaultNarrowingHelper->toSureTypes($rightTypes, $evaluationScope)); + } + + /** + * Identity narrowing of a subject against a known constant type - the + * entry point for callers that hold no comparison node at all (the + * assign-time conditional holders compare the assigned expression with + * falsy sentinels). $constantExpr is only printed into reverse entries, + * never walked. Null means the shape is not composed and the caller + * keeps its old-world path. + * + * @param callable(): Type $identicalTypeCallback + */ + public function specifyIdenticalAgainstType( + Expr $subject, + ExpressionResult $subjectResult, + Expr $constantExpr, + Type $constantType, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ?ExpressionResult $subjectArgResult, + callable $identicalTypeCallback, + ): ?SpecifiedTypes + { + if ($context->null()) { + return null; + } + + if ($constantType->isNull()->yes()) { + // unguarded like the null-literal slice - the subtraction entry + // must keep firing conditional holders + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new NullType(), $context); + } + + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + + if ($constantType->isTrue()->yes() || $constantType->isFalse()->yes()) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new ConstantBooleanType($constantType->isTrue()->yes()), $context); + if (!$context->true() && ($unwrappedSubject instanceof Expr\NullsafeMethodCall || $unwrappedSubject instanceof Expr\NullsafePropertyFetch)) { + return $types; + } + + $boolContext = $constantType->isTrue()->yes() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); + + return $types->unionWith($subjectResult->getSpecifiedTypesForScope( + $evaluationScope, + $context->true() ? $boolContext : $boolContext->negate(), + )); + } + + if ($unwrappedSubject instanceof Expr\FuncCall) { + $familyTypes = $this->specifyFuncCallFamilies($subject, $subjectResult, $unwrappedSubject, $constantExpr, $constantType, $context, $evaluationScope, $subjectArgResult); + if ($familyTypes === null) { + return null; + } + if ($familyTypes !== false) { + return $familyTypes; + } + } elseif ($unwrappedSubject instanceof Expr\ClassConstFetch && $unwrappedSubject->class instanceof Expr) { + return null; + } + + if ($context->false()) { + $identicalType = $identicalTypeCallback(); + $isTrue = $identicalType->isTrue()->yes(); + if ($isTrue || $identicalType->isFalse()->yes()) { + $never = new NeverType(); + $contextForTypes = $isTrue ? $context->negate() : $context; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $never, $contextForTypes)->unionWith( + $this->defaultNarrowingHelper->createForSubject($constantExpr, $never, $contextForTypes, $evaluationScope), + ); + } + } + + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context); + + $subjectType = $subjectResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + if (count($subjectType->getFiniteTypes()) === 1) { + $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject($constantExpr, $subjectType, $context, $evaluationScope)); + } + + return $types; + } + + /** + * The == narrowing against a single-valued side: a family answer, null + * to fall back to the old-world path, or false when nothing matched and + * the caller continues with the coercion branches. + * + * @param callable(): Type $identicalTypeCallback + * @return SpecifiedTypes|false|null + */ + private function specifyEqualAgainstConstantSide( + NodeScopeResolver $nodeScopeResolver, + Expr $left, + Expr $right, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + Expr $subject, + ExpressionResult $subjectResult, + mixed $value, + Type $constantType, + Type $otherType, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ?ExpressionResult $leftArgResult, + ?ExpressionResult $rightArgResult, + callable $identicalTypeCallback, + ): SpecifiedTypes|false|null + { + $unwrappedSubject = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + + if ($value === null) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new UnionType([ + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType(''), + new ConstantArrayType([], []), + ]), $context); + } + + // a bool constant compares by the subject's truthiness + if ($value === false) { + return $subjectResult->getSpecifiedTypesForScope( + $evaluationScope, + $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), + ); + } + if ($value === true) { + return $subjectResult->getSpecifiedTypesForScope( + $evaluationScope, + $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), + ); + } + + /* There is a difference between php 7.x and 8.x on the equality + * behavior between zero and the empty string, so to be conservative + * we leave it untouched regardless of the language version */ + if ($value === 0 && !$otherType->isInteger()->yes() && !$otherType->isBoolean()->yes()) { + $trueTypes = $context->true() + ? [new NullType(), new ConstantBooleanType(false), new ConstantIntegerType(0), new ConstantFloatType(0.0), new StringType()] + : [new NullType(), new ConstantBooleanType(false), new ConstantIntegerType(0), new ConstantFloatType(0.0), new ConstantStringType('0')]; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new UnionType($trueTypes), $context); + } + if ($value === '') { + $trueTypes = $context->true() + ? [new NullType(), new ConstantBooleanType(false), new ConstantIntegerType(0), new ConstantFloatType(0.0), new ConstantStringType('')] + : [new NullType(), new ConstantBooleanType(false), new ConstantStringType('')]; + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, new UnionType($trueTypes), $context); + } + + // loose equals strict for these call results and class names + if ( + $unwrappedSubject instanceof Expr\FuncCall + && $unwrappedSubject->name instanceof Name + && !$unwrappedSubject->isFirstClassCallable() + && isset($unwrappedSubject->getArgs()[0]) + ) { + $funcName = $unwrappedSubject->name->toLowerString(); + if (in_array($funcName, ['gettype', 'get_class', 'get_debug_type'], true) && $constantType->isString()->yes()) { + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + } + if ($context->true() && $funcName === 'preg_match' && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) { + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + } + } + if ( + $unwrappedSubject instanceof Expr\ClassConstFetch + && !($unwrappedSubject->name instanceof Expr) + && $unwrappedSubject->name->toLowerString() === 'class' + && $constantType->isString()->yes() + ) { + return $this->specifyIdentical($nodeScopeResolver, $left, $right, $leftResult, $rightResult, $context, $evaluationScope, $leftArgResult, $rightArgResult, $identicalTypeCallback); + } + + return false; + } + + /** + * The function-family compositions, shared by the literal and the + * TYPE-based constant sides: a family answer, null to fall back to the + * old-world path, or false when no family matched and the caller narrows + * generically. + * + * @return SpecifiedTypes|false|null + */ + private function specifyFuncCallFamilies( + Expr $subject, + ExpressionResult $subjectResult, + Expr\FuncCall $call, + Expr $constantExpr, + Type $constantType, + TypeSpecifierContext $context, + MutatingScope $evaluationScope, + ?ExpressionResult $argResult, + ): SpecifiedTypes|false|null + { + if (!($call->name instanceof Name) || $call->isFirstClassCallable() || !isset($call->getArgs()[0])) { + return false; + } + + // preg_match(...) === 1 is the call's own truthy narrowing - the + // type-specifying extensions narrow the by-ref \$matches argument + if ( + $call->name->toLowerString() === 'preg_match' + ) { + if ($context->true() && (new ConstantIntegerType(1))->isSuperTypeOf($constantType)->yes()) { + return $subjectResult->getSpecifiedTypesForScope($evaluationScope, $context); + } + + // other constants and contexts only pin the call below + } + + // a trimmed string that is not '' was a non-empty string already + if ( + in_array($call->name->toLowerString(), ['trim', 'ltrim', 'rtrim', 'chop', 'mb_trim', 'mb_ltrim', 'mb_rtrim'], true) + ) { + if ($context->false()) { + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') { + $argExpr = $call->getArgs()[0]->value; + if ($argResult === null) { + return null; + } + if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) { + return $this->defaultNarrowingHelper->createForSubject( + $argExpr, + new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]), + $context->negate(), + $evaluationScope, + ); + } + } + } + + // other constants and contexts only pin the call + } + + // a known parent class narrows the argument to the child side of it + if ($call->name->toLowerString() === 'get_parent_class') { + if ($context->true()) { + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { + $argExpr = $call->getArgs()[0]->value; + if ($argResult === null) { + return null; + } + $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + $objectType = new ObjectType($constantStrings[0]->getValue()); + $classStringType = new GenericClassStringType($objectType); + + if ($argType->isString()->yes()) { + $narrowed = $classStringType; + } elseif ($argType->isObject()->yes()) { + $narrowed = $objectType; + } else { + $narrowed = TypeCombinator::union($objectType, $classStringType); + } + + return $this->defaultNarrowingHelper->createForSubject($argExpr, $narrowed, $context, $evaluationScope); + } + } + + // other contexts and non-single class names only pin the call + } + + // a string function whose result is a non-empty literal had a + // non-empty (non-falsy for a non-falsy literal) string argument; + // case-mapping functions pin the case accessory on the literal side + if ( + in_array($call->name->toLowerString(), [ + 'substr', 'strstr', 'stristr', 'strchr', 'strrchr', 'strtolower', 'strtoupper', 'ucfirst', 'lcfirst', + 'mb_substr', 'mb_strstr', 'mb_stristr', 'mb_strchr', 'mb_strrchr', 'mb_strtolower', 'mb_strtoupper', 'mb_ucfirst', 'mb_lcfirst', + 'ucwords', 'mb_convert_case', 'mb_convert_kana', + ], true) + ) { + if ($context->truthy() && $constantType->isNonEmptyString()->yes()) { + $argExpr = $call->getArgs()[0]->value; + if ($argResult === null) { + return null; + } + $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + if ($argType->isString()->yes()) { + $types = new SpecifiedTypes(); + $funcName = $call->name->toLowerString(); + if (in_array($funcName, ['strtolower', 'mb_strtolower'], true)) { + $types = $this->defaultNarrowingHelper->createForSubject($constantExpr, TypeCombinator::intersect($constantType, new AccessoryLowercaseStringType()), $context, $evaluationScope); + } elseif (in_array($funcName, ['strtoupper', 'mb_strtoupper'], true)) { + $types = $this->defaultNarrowingHelper->createForSubject($constantExpr, TypeCombinator::intersect($constantType, new AccessoryUppercaseStringType()), $context, $evaluationScope); + } + + $accessory = $constantType->isNonFalsyString()->yes() + ? new AccessoryNonFalsyStringType() + : new AccessoryNonEmptyStringType(); + + return $types->unionWith($this->defaultNarrowingHelper->createForSubject( + $argExpr, + TypeCombinator::intersect($argType, $accessory), + $context, + $evaluationScope, + )); + } + } + + // a non-string argument, an empty literal or a non-truthy + // context only pins the call + } + + // count($x) === N reconstructs the array shape by its size - before + // the decided guard so exhaustive size switches keep collapsing + if ( + in_array($call->name->toLowerString(), ['count', 'sizeof'], true) + ) { + if (!$constantType->isInteger()->yes()) { + return null; + } + + $argExpr = $call->getArgs()[0]->value; + if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($constantType)->yes()) { + return $this->defaultNarrowingHelper->createForSubject($argExpr, new NeverType(), $context, $evaluationScope); + } + + if ($argResult === null) { + return null; + } + $argType = $argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted); + + if ((new ConstantIntegerType(0))->isSuperTypeOf($constantType)->yes()) { + $newArgType = $context->truthy() && !$argType->isArray()->yes() + ? new UnionType([new ObjectType(Countable::class), new ConstantArrayType([], [])]) + : new ConstantArrayType([], []); + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, $newArgType, $context, $evaluationScope), + ); + } + + $countTypes = $this->countNarrowingHelper->specifyCountSize($call, $argType, $constantType, $context, $evaluationScope, $call); + if ($countTypes !== null) { + // the old path pinned the call only through the remembered + // wrapper; the composed pin covers wrapper and call alike + if ($subject !== $call) { + return $countTypes->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)); + } + + return $countTypes; + } + + if ($context->truthy() && $argType->isArray()->yes()) { + $types = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context); + if (IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($constantType)->yes()) { + return $types->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, new NonEmptyArrayType(), $context, $evaluationScope), + ); + } + + return $types; + } + + // a non-array argument in a non-truthy context only pins the call + } + + // strlen($x) === 0 empties $x; === N >= 1 makes it non-empty in the + // truthy direction (>= 2 non-falsy) - before the decided guard + if ( + in_array($call->name->toLowerString(), ['strlen', 'mb_strlen'], true) + ) { + if (count($call->getArgs()) !== 1 || !$constantType->isInteger()->yes()) { + return null; + } + + $argExpr = $call->getArgs()[0]->value; + if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($constantType)->yes()) { + return $this->defaultNarrowingHelper->createForSubject($argExpr, new NeverType(), $context, $evaluationScope); + } + + if ((new ConstantIntegerType(0))->isSuperTypeOf($constantType)->yes()) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, new ConstantStringType(''), $context, $evaluationScope), + ); + } + + if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($constantType)->yes()) { + if ($argResult === null) { + return null; + } + if ($argResult->getTypeOnScope($evaluationScope, $evaluationScope->nativeTypesPromoted)->isString()->yes()) { + $accessory = IntegerRangeType::fromInterval(2, null)->isSuperTypeOf($constantType)->yes() + ? new AccessoryNonFalsyStringType() + : new AccessoryNonEmptyStringType(); + + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, $accessory, $context, $evaluationScope), + ); + } + } + + // a non-string argument or a falsey non-zero size only pins the call + } + + // gettype($x) === 'string' narrows $x by the named type in either + // direction - before the decided-comparison guard, like the old block + if ( + $call->name->toLowerString() === 'gettype' + ) { + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) > 1) { + // a union of type names narrows by the intersection of the + // per-name narrowings + $intersectedTypes = null; + foreach ($constantStrings as $constantString) { + $mapped = $this->getTypeFromGettypeStringValue($constantString->getValue()); + if ($mapped === null) { + continue; + } + $one = $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantString, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($call->getArgs()[0]->value, $mapped, $context, $evaluationScope), + ); + $intersectedTypes = $intersectedTypes === null ? $one : $intersectedTypes->intersectWith($one); + } + if ($intersectedTypes !== null) { + return $intersectedTypes; + } + + // no known type names - only pin the call + } + if (count($constantStrings) === 1) { + $gettypeNarrowedType = $this->getTypeFromGettypeStringValue($constantStrings[0]->getValue()); + if ($gettypeNarrowedType !== null) { + return $this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)->unionWith( + $this->defaultNarrowingHelper->createForSubject($call->getArgs()[0]->value, $gettypeNarrowedType, $context, $evaluationScope), + ); + } + // an unknown type-name string only pins the call itself below + } + + // a non-constant string side only pins the call + } + + // get_class($o) === 'Foo' pins $o to a final Foo when the comparison + // holds; outside the true context only the call itself narrows + if (in_array($call->name->toLowerString(), ['get_class', 'get_debug_type'], true) && $context->true()) { + $narrowedObjectType = null; + $constantStrings = $constantType->getConstantStrings(); + if (count($constantStrings) === 1 && $this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { + $narrowedObjectType = new ObjectType($constantStrings[0]->getValue(), classReflection: $this->reflectionProvider->getClass($constantStrings[0]->getValue())->asFinal()); + } elseif ($constantType->getClassStringObjectType()->isObject()->yes()) { + $narrowedObjectType = $constantType->getClassStringObjectType(); + } + + if ($narrowedObjectType !== null) { + return $this->defaultNarrowingHelper->createForSubject( + $call->getArgs()[0]->value, + $narrowedObjectType, + $context, + $evaluationScope, + )->unionWith($this->defaultNarrowingHelper->createSubjectTypes($evaluationScope, $subject, $subjectResult, $constantType, $context)); + } + } + + return false; + } + + /** + * The first argument's stored result of a (possibly remembered) call + * operand, captured by the seams at create time - the composed function + * families read it instead of the asking scope's storage stack (ask-time + * state that differs between main-pass and post-walk asks and would + * break memoizing the narrowing per context). Capturing the result, not + * the storage, keeps retention bounded. + */ + public function captureFirstArgResult(Expr $side, ExpressionResultStorage $storage): ?ExpressionResult + { + $unwrapped = $side instanceof AlwaysRememberedExpr ? $side->getExpr() : $side; + if (!$unwrapped instanceof Expr\FuncCall || $unwrapped->isFirstClassCallable() || !isset($unwrapped->getArgs()[0])) { + return null; + } + + return $storage->findExpressionResult($unwrapped->getArgs()[0]->value); + } + + /** The static type of a literal node - no result or scope needed. */ + private function literalType(Expr $expr): ?Type + { + if ($expr instanceof Scalar\Int_) { + return new ConstantIntegerType($expr->value); + } + if ($expr instanceof Scalar\Float_) { + return new ConstantFloatType($expr->value); + } + if ($expr instanceof Scalar\String_) { + return new ConstantStringType($expr->value); + } + if ($expr instanceof Expr\ConstFetch) { + $name = $expr->name->toLowerString(); + if ($name === 'true') { + return new ConstantBooleanType(true); + } + if ($name === 'false') { + return new ConstantBooleanType(false); + } + if ($name === 'null') { + return new NullType(); + } + } + + return null; + } + + private function isScalarLiteral(Expr $expr): bool + { + if ($expr instanceof Scalar\Int_ || $expr instanceof Scalar\String_ || $expr instanceof Scalar\Float_) { + return true; + } + + // Foo::BAR, Suit::Hearts, Foo::class - but not $a::class, whose + // narrowing works on $a (an old-world block, not ported yet) + return $expr instanceof Expr\ClassConstFetch + && $expr->class instanceof Name + && !$expr->name instanceof Expr; + } + + /** + * Subjects whose comparison against a constant narrows more than the + * subject expression itself stay on the old-world path for now: function + * calls narrow their arguments (count($a) === 0 empties $a), `$a::class` + * narrows $a. The null comparison is fully composed (the array_key_first + * family narrows its argument through the FuncCall's createTypesCallback) + * and does not consult this. + */ + private function isSubjectCoveredAgainstConstant(Expr $subject): bool + { + $unwrapped = $subject instanceof AlwaysRememberedExpr ? $subject->getExpr() : $subject; + if ($unwrapped instanceof Expr\FuncCall) { + return false; + } + + return !($unwrapped instanceof Expr\ClassConstFetch && $unwrapped->class instanceof Expr); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/class-constant-comparison-narrowing.php b/tests/PHPStan/Analyser/nsrt/class-constant-comparison-narrowing.php new file mode 100644 index 00000000000..d6098778fe0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-constant-comparison-narrowing.php @@ -0,0 +1,43 @@ += 8.0 + +namespace ClassConstantComparisonNarrowing; + +use function PHPStan\Testing\assertType; + +class Foo +{ +} + +class A +{ + + public const TYPE = 'ClassConstantComparisonNarrowing\Foo'; + +} + +class B +{ + + public const TYPE = 'Bar'; + +} + +function nonClassConstantIsNotClassNameNarrowing(A|B $obj): void +{ + if ($obj::TYPE === 'ClassConstantComparisonNarrowing\Foo') { + assertType('ClassConstantComparisonNarrowing\A|ClassConstantComparisonNarrowing\B', $obj); + } else { + assertType('ClassConstantComparisonNarrowing\A|ClassConstantComparisonNarrowing\B', $obj); + } + + if ($obj::TYPE === 'Bar') { + assertType('ClassConstantComparisonNarrowing\A|ClassConstantComparisonNarrowing\B', $obj); + } +} + +function classConstantStillNarrows(object $obj): void +{ + if ($obj::class === Foo::class) { + assertType('ClassConstantComparisonNarrowing\Foo', $obj); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/class-name-comparison-unknown-class.php b/tests/PHPStan/Analyser/nsrt/class-name-comparison-unknown-class.php new file mode 100644 index 00000000000..742a9f660e7 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-name-comparison-unknown-class.php @@ -0,0 +1,23 @@ += 8.1 + +declare(strict_types = 1); + +namespace EqualityNarrowingNewWorld; + +use function PHPStan\Testing\assertType; + +class Foo +{ + + public const BAR = 'bar'; + +} + +enum Suit: string +{ + + case Hearts = 'H'; + case Spades = 'S'; + +} + +class Basics +{ + + public function identicalNull(?int $a): void + { + if ($a === null) { + assertType('null', $a); + } else { + assertType('int', $a); + } + if ($a !== null) { + assertType('int', $a); + } else { + assertType('null', $a); + } + if (null === $a) { + assertType('null', $a); + } else { + assertType('int', $a); + } + } + + public function identicalLiteral(int $a, string $s): void + { + if ($a === 5) { + assertType('5', $a); + } else { + assertType('int|int<6, max>', $a); + } + if (5 === $a) { + assertType('5', $a); + } + if ($s === 'foo') { + assertType("'foo'", $s); + } else { + assertType("string", $s); + } + } + + public function identicalBool(bool $b, int $i): void + { + if ($b === true) { + assertType('true', $b); + } else { + assertType('false', $b); + } + if ($b !== false) { + assertType('true', $b); + } + if (($i > 3) === true) { + assertType('int<4, max>', $i); + } + if (($i > 3) === false) { + assertType('int', $i); + } + } + + /** + * @param 'a'|'b'|'c' $abc + * @param int|string $is + */ + public function unionMembers(string $abc, $is): void + { + if ($abc === 'b') { + assertType("'b'", $abc); + } else { + assertType("'a'|'c'", $abc); + } + if ($is === 'x') { + assertType("'x'", $is); + } else { + assertType('int|string', $is); + } + } + + /** + * @param int|null $a + * @param string|null $b + */ + public function bothSidesSpecifiable($a, $b): void + { + if ($a === $b) { + assertType('null', $a); + assertType('null', $b); + } + if ($a !== $b) { + // nothing certain about either side + assertType('int|null', $a); + assertType('string|null', $b); + } + } + + public function enumCases(Suit $suit): void + { + if ($suit === Suit::Hearts) { + assertType('EqualityNarrowingNewWorld\Suit::Hearts', $suit); + } else { + assertType('EqualityNarrowingNewWorld\Suit::Spades', $suit); + } + if ($suit !== Suit::Spades) { + assertType('EqualityNarrowingNewWorld\Suit::Hearts', $suit); + } + } + + public function classConstant(string $s): void + { + if ($s === Foo::BAR) { + assertType("'bar'", $s); + } + } + + /** @param array $arr */ + public function countNarrowing(array $arr): void + { + if (count($arr) === 0) { + assertType('array{}', $arr); + } else { + assertType('non-empty-array', $arr); + } + if (count($arr) === 2) { + assertType('non-empty-array', $arr); + } + if (count($arr) !== 0) { + assertType('non-empty-array', $arr); + } + } + + /** + * @param list $list + * @param array{a: int, b?: string} $shape + * @param array $ints + */ + public function countNarrowingShapes(array $list, array $shape, array $ints): void + { + if (count($list) === 2) { + assertType('array{string, string}', $list); + } else { + assertType('list', $list); + } + if (count($list) !== 1) { + assertType('list', $list); + } else { + assertType('array{string}', $list); + } + if (count($shape) === 1) { + assertType('array{a: int, b?: string}', $shape); + } + if (sizeof($ints) === 0) { + assertType('array{}', $ints); + } + if (count($list, COUNT_RECURSIVE) === 2) { + // non-nested list: recursive count equals normal count + assertType('array{string, string}', $list); + } + } + + public function strlenNarrowing(string $s): void + { + if (strlen($s) === 0) { + assertType("''", $s); + } else { + assertType('non-empty-string', $s); + } + if (strlen($s) !== 0) { + assertType('non-empty-string', $s); + } + if (strlen($s) === 1) { + assertType('non-empty-string', $s); + } + if (strlen($s) === 2) { + assertType('non-falsy-string', $s); + } + if (mb_strlen($s) === 0) { + assertType("''", $s); + } else { + assertType('non-empty-string', $s); + } + } + + public function substrFamilyNarrowing(string $s): void + { + if (substr($s, 0, 3) === 'foo') { + assertType('non-falsy-string', $s); + } + if (strtolower($s) === 'abc') { + assertType('non-falsy-string', $s); + } + if (strtoupper($s) === '0') { + assertType('non-empty-string', $s); + } + if (ucfirst($s) === 'Foo') { + assertType('non-falsy-string', $s); + } else { + assertType('string', $s); + } + } + + /** @param mixed $m */ + public function trimAndParentClass(string $s, object $o, $m): void + { + if (trim($s) !== '') { + assertType('non-empty-string', $s); + } + if (ltrim($s) === '') { + assertType('string', $s); + } else { + assertType('non-empty-string', $s); + } + if (get_parent_class($o) === Foo::class) { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } + if (get_parent_class($m) === Foo::class) { + assertType('class-string|EqualityNarrowingNewWorld\Foo', $m); + } + } + + public function getClassNarrowing(object $o): void + { + if (get_class($o) === Foo::class) { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } else { + assertType('object', $o); + } + if (Foo::class === get_class($o)) { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } + if (get_debug_type($o) === Foo::class) { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } + if (get_class($o) !== Foo::class) { + assertType('object', $o); + } else { + assertType('EqualityNarrowingNewWorld\Foo', $o); + } + } + + /** + * @param int|string $is + * @param mixed $m + */ + public function gettypeNarrowing($is, $m): void + { + if (gettype($is) === 'string') { + assertType('string', $is); + } else { + assertType('int', $is); + } + if (gettype($m) === 'NULL') { + assertType('null', $m); + } + if (gettype($is) !== 'integer') { + assertType('string', $is); + } else { + assertType('int', $is); + } + if (gettype($m) === 'double') { + assertType('float', $m); + } + } + + public function pregMatchNarrowing(string $s): void + { + if (preg_match('/^a(b)c$/', $s, $matches) === 1) { + assertType("array{non-falsy-string, 'b'}", $matches); + } + if (1 === preg_match('/^a(b)c$/', $s, $matches2)) { + assertType("array{non-falsy-string, 'b'}", $matches2); + } + if (preg_match('/^a(b)c$/', $s, $matches3) === 0) { + assertType("array{}|array{non-falsy-string, 'b'}", $matches3); + } + } + + /** + * @param 5 $five + * @param int|string $is + * @param Suit $suit + * @param Suit $otherSuit + * @param array{a: int}|null $arrOrNull + * @param array{a: int}|false $arrOrFalse + */ + public function generalExprVsExpr($five, $is, Suit $suit, Suit $otherSuit, $arrOrNull, $arrOrFalse, ?int $ni): void + { + if ($is === $five) { + assertType('5', $is); + } else { + assertType('int|int<6, max>|string', $is); + } + if ($suit === $otherSuit) { + assertType('EqualityNarrowingNewWorld\\Suit', $suit); + } else { + assertType('EqualityNarrowingNewWorld\\Suit', $suit); + } + if ($arrOrNull === $ni) { + assertType('null', $arrOrNull); + assertType('null', $ni); + } + if ($arrOrFalse === $arrOrNull) { + assertType('array{a: int}', $arrOrFalse); + assertType('array{a: int}', $arrOrNull); + } + } + + /** + * @param int<2, 3> $smallSize + * @param 0 $zero + * @param 'string' $stringName + * @param list $list + * @param mixed $m + */ + public function typeBasedConstantSides(array $list, string $s, int $smallSize, int $zero, string $stringName, $m): void + { + if (count($list) === $smallSize) { + assertType('array{0: string, 1: string, 2?: string}', $list); + } + if (count($list) === $zero) { + assertType('array{}', $list); + } + if (strlen($s) === $smallSize) { + assertType('non-falsy-string', $s); + } + if (gettype($m) === $stringName) { + assertType('string', $m); + } else { + assertType('mixed~string', $m); + } + } + + public function classConstFetchNarrowing(object $o): void + { + if ($o::class === Foo::class) { + assertType('EqualityNarrowingNewWorld\\Foo', $o); + } else { + assertType('object', $o); + } + if (Foo::class === $o::class) { + assertType('EqualityNarrowingNewWorld\\Foo', $o); + } + if ($o::class !== Foo::class) { + assertType('object', $o); + } else { + assertType('EqualityNarrowingNewWorld\\Foo', $o); + } + if ($o::class === 'EqualityNarrowingNewWorld\\Foo') { + assertType('object', $o); + } + } + + /** @param mixed $m */ + public function looseEquality($m, ?string $s): void + { + if ($m == null) { + assertType("0|0.0|''|array{}|false|null", $m); + } else { + assertType("mixed~(0|0.0|''|array{}|false|null)", $m); + } + if ($s == false) { + assertType("''|'0'|null", $s); + } else { + assertType('non-falsy-string', $s); + } + if ($s != null) { + assertType('non-empty-string', $s); + } else { + assertType("''|null", $s); + } + } + + /** + * @param int|string $is + * @param array $arr + * @param 'a'|'b' $ab + */ + public function moreLooseEquality(?bool $nb, $is, string $s, array $arr, string $ab, Suit $suit, Suit $otherSuit): void + { + if ($nb == true) { + assertType('true', $nb); + } else { + assertType('false|null', $nb); + } + if ($is == 0) { + assertType('0|string', $is); + } else { + assertType('int|int<1, max>|string', $is); + } + if ($is == '') { + assertType("0|''", $is); + } else { + assertType('int|non-empty-string', $is); + } + if ($s == 'foo') { + assertType("'foo'", $s); + } + if ($ab == 'a') { + assertType("'a'", $ab); + } else { + assertType("'b'", $ab); + } + if ($arr == []) { + assertType('array{}', $arr); + } else { + assertType('non-empty-array', $arr); + } + if (gettype($is) == 'string') { + assertType('string', $is); + } else { + assertType('int', $is); + } + if ($suit == $otherSuit) { + assertType('EqualityNarrowingNewWorld\\Suit', $suit); + } + } + + /** + * @param int|string $a + */ + public function narrowAgainstExpression($a, int $b): void + { + if ($a === $b) { + assertType('int', $a); + } else { + assertType('int|string', $a); + } + } + + public function nestedInBoolean(?int $a, ?string $b): void + { + if ($a !== null && $b !== null) { + assertType('int', $a); + assertType('string', $b); + } + if ($a === null || $b === null) { + assertType('int|null', $a); + } else { + assertType('int', $a); + assertType('string', $b); + } + } + + /** @var self|null */ + private $selfOrNull; + + public function propertyChain(): void + { + if ($this->selfOrNull !== null) { + assertType('EqualityNarrowingNewWorld\Basics', $this->selfOrNull); + } + } + + public function assignInCondition(?int $a): void + { + if (($b = $a) !== null) { + assertType('int', $b); + assertType('int', $a); + } + } + + public function flag(): bool + { + return true; + } + + /** @param array|false $arrOrFalse */ + public function boolConstAgainstExpressions(?self $s, $arrOrFalse, ?bool $nb): void + { + if ($s?->flag() === false) { + assertType('EqualityNarrowingNewWorld\Basics', $s); + } else { + assertType('EqualityNarrowingNewWorld\Basics|null', $s); + } + if ($s?->flag() === true) { + assertType('EqualityNarrowingNewWorld\Basics', $s); + } + if ($arrOrFalse !== false) { + assertType('array', $arrOrFalse); + } else { + assertType('false', $arrOrFalse); + } + if ($nb === true) { + assertType('true', $nb); + } else { + assertType('false|null', $nb); + } + if ($nb !== false) { + assertType('true|null', $nb); + } else { + assertType('false', $nb); + } + } + + /** @param list $list */ + public function funcCallAgainstNull(array $list): void + { + if (array_key_first($list) !== null) { + assertType('non-empty-list', $list); + } + if (($key = array_key_first($list)) !== null) { + assertType('int<0, max>', $key); + assertType('non-empty-list', $list); + assertType('string', $list[$key]); + } + if (array_key_first($list) === null) { + assertType('array{}', $list); + } else { + assertType('non-empty-list', $list); + } + if (array_key_last($list) !== null) { + assertType('non-empty-list', $list); + } + if (array_find_key($list, static fn (string $v): bool => $v !== '') !== null) { + assertType('non-empty-list', $list); + } else { + // an empty find result does not mean an empty array + assertType('list', $list); + } + } + +} From 450a42787b42a8c96cd258a1aa08a4a8edee6504 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:33 +0200 Subject: [PATCH 06/32] Compose boolean narrowing from operand results BooleanNarrowingHelper owns the && and || narrowing semantics parameterised over per-operand closures, so conjunctions and disjunctions without a real AST node (ternary decomposition, empty(), multi-subject isset, nullsafe receiver fans) reuse the same logic. The right side is walked once on the left-truthy scope and its result consumed, which deletes the flattening machinery and the BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH cap from BooleanAndHandler and BooleanOrHandler: deep chains now cost O(n), covered by the and-chain bench fixture. The disjunction augments and the conditional-expression holder helper stop asking the scope to re-price candidates and read scope state or the composed subject types instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- .../ConditionalExpressionHolderRecipe.php | 4 +- .../DisjunctionBranchUnionAugment.php | 8 +- .../DisjunctionHolderProjectionAugment.php | 23 +- .../ExprHandler/BooleanAndHandler.php | 269 +++-------------- .../ExprHandler/BooleanNotHandler.php | 45 +-- src/Analyser/ExprHandler/BooleanOrHandler.php | 274 ++++-------------- .../Helper/BooleanNarrowingHelper.php | 255 ++++++++++++++++ .../ConditionalExpressionHolderHelper.php | 31 +- tests/PHPStan/Analyser/nsrt/bug-14908.php | 44 +++ ...ditional-expr-narrowing-second-operand.php | 31 ++ .../Analyser/nsrt/deep-boolean-and-chain.php | 106 +++++++ .../Rules/Comparison/data/bug-14908.php | 6 +- .../Rules/Functions/data/bug-13334.php | 50 ++++ .../data/and-chain-resolve-type-blowup.php | 119 ++++++++ 14 files changed, 780 insertions(+), 485 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14908.php create mode 100644 tests/PHPStan/Analyser/nsrt/conditional-expr-narrowing-second-operand.php create mode 100644 tests/PHPStan/Analyser/nsrt/deep-boolean-and-chain.php create mode 100644 tests/PHPStan/Rules/Functions/data/bug-13334.php create mode 100644 tests/bench/data/and-chain-resolve-type-blowup.php diff --git a/src/Analyser/ConditionalExpressionHolderRecipe.php b/src/Analyser/ConditionalExpressionHolderRecipe.php index 4a6aa4e3f7b..93a9e7b3a83 100644 --- a/src/Analyser/ConditionalExpressionHolderRecipe.php +++ b/src/Analyser/ConditionalExpressionHolderRecipe.php @@ -44,7 +44,7 @@ public function evaluate(MutatingScope $scope): array // dropped-self-condition complement below $conditionOriginalTypes = []; foreach ($this->conditionEntries as [$exprString, $expr, $fromSureTypes, $type]) { - $scopeType = $scope->getType($expr); + $scopeType = $scope->getStateType($expr); $conditionType = $fromSureTypes ? TypeCombinator::remove($scopeType, $type) : TypeCombinator::intersect($scopeType, $type); @@ -81,7 +81,7 @@ public function evaluate(MutatingScope $scope): array continue; } - $targetType = $pinnedTargetType ?? $scope->getType($expr); + $targetType = $pinnedTargetType ?? $scope->getStateType($expr); $holderType = $this->holdersFromSureTypes ? TypeCombinator::intersect($targetType, $type) : TypeCombinator::remove($targetType, $type); diff --git a/src/Analyser/DisjunctionBranchUnionAugment.php b/src/Analyser/DisjunctionBranchUnionAugment.php index 75ebf96dd56..5bd785b1455 100644 --- a/src/Analyser/DisjunctionBranchUnionAugment.php +++ b/src/Analyser/DisjunctionBranchUnionAugment.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; @@ -23,7 +24,8 @@ final class DisjunctionBranchUnionAugment implements DeferredSpecifiedTypesAugme * @param list $candidates [target expr, left branch type, right branch type] */ public function __construct( - private TypeSpecifier $typeSpecifier, + private NodeScopeResolver $nodeScopeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, private array $candidates, ) { @@ -38,7 +40,7 @@ public function evaluate(MutatingScope $scope): ?SpecifiedTypes } // the guard above pins the target as tracked on the applying scope - $originalType = $scope->getType($targetExpr); + $originalType = $this->nodeScopeResolver->readScopeStateOrSyntheticType($targetExpr, $scope); // re-pinning eagerly priced branch forms of a template-typed subject // stacks the template inside its own bound (`T of T of ...` - the // pin intersects with the declared template); its narrowing already @@ -62,7 +64,7 @@ public function evaluate(MutatingScope $scope): ?SpecifiedTypes continue; } - $created = $this->typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope); + $created = $this->defaultNarrowingHelper->createForSubject($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope); $result = $result === null ? $created : $result->unionWith($created); } diff --git a/src/Analyser/DisjunctionHolderProjectionAugment.php b/src/Analyser/DisjunctionHolderProjectionAugment.php index bc514bc9aed..db98ecbd861 100644 --- a/src/Analyser/DisjunctionHolderProjectionAugment.php +++ b/src/Analyser/DisjunctionHolderProjectionAugment.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Type\TypeCombinator; use function array_key_first; @@ -29,7 +30,8 @@ final class DisjunctionHolderProjectionAugment implements DeferredSpecifiedTypes * be added on top */ public function __construct( - private TypeSpecifier $typeSpecifier, + private NodeScopeResolver $nodeScopeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, private $leftTruthyScope, private MutatingScope $leftFalseyScope, private $rightTruthyScope, @@ -69,19 +71,24 @@ public function evaluate(MutatingScope $scope): ?SpecifiedTypes } $leftTruthyScope ??= ($this->leftTruthyScope)(); $rightTruthyScope ??= ($this->rightTruthyScope)(); + if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } - // the guard above pins the target as tracked on the applying - // scope; the branch scopes are its own filtered views, so their - // reads answer from state (or price the same tracked state) - $origType = $scope->getType($targetExpr); + // the guards above pin the target as tracked on all three scopes - + // scope state answers without a walk + $origType = $this->nodeScopeResolver->readScopeStateOrSyntheticType($targetExpr, $scope); - $leftType = $leftTruthyScope->getType($targetExpr); + $leftType = $this->nodeScopeResolver->readScopeStateOrSyntheticType($targetExpr, $leftTruthyScope); $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); if (!$leftNarrowed) { continue; } - $rightType = $rightTruthyScope->getType($targetExpr); + $rightType = $this->nodeScopeResolver->readScopeStateOrSyntheticType($targetExpr, $rightTruthyScope); $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); if (!$rightNarrowed) { continue; @@ -92,7 +99,7 @@ public function evaluate(MutatingScope $scope): ?SpecifiedTypes continue; } - $created = $this->typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope); + $created = $this->defaultNarrowingHelper->createSubjectTypes($scope, $targetExpr, null, $unionType, TypeSpecifierContext::createTrue()); $result = $result === null ? $created : $result->unionWith($created); } } diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index ea3cea735d8..1d6c3d02ac1 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -4,35 +4,25 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\BooleanAnd; -use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\BinaryOp\LogicalAnd; -use PhpParser\Node\Expr\BinaryOp\LogicalOr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\BooleanAndNode; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use function array_filter; use function array_merge; -use function array_reverse; -use function array_values; -use function is_string; /** * @implements ExprHandler @@ -41,11 +31,8 @@ final class BooleanAndHandler implements ExprHandler { - private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; - public function __construct( - private NodeScopeResolver $nodeScopeResolver, - private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, private ExpressionResultFactory $expressionResultFactory, ) { @@ -56,213 +43,6 @@ public function supports(Expr $expr): bool return $expr instanceof BooleanAnd || $expr instanceof LogicalAnd; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $leftBooleanType = $scope->getType($expr->left)->toBoolean(); - if ($leftBooleanType->isFalse()->yes()) { - return new ConstantBooleanType(false); - } - - if (self::getBooleanExpressionDepth($expr->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - $leftResult = $this->nodeScopeResolver->processExprNode(new Stmt\Expression($expr->left), $expr->left, $scope, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep()); - $rightBooleanType = $leftResult->getTruthyScope()->getType($expr->right)->toBoolean(); - } else { - $rightBooleanType = $scope->filterByTruthyValue($expr->left)->getType($expr->right)->toBoolean(); - } - - if ($rightBooleanType->isFalse()->yes()) { - return new ConstantBooleanType(false); - } - - if ( - $leftBooleanType->isTrue()->yes() - && $rightBooleanType->isTrue()->yes() - ) { - return new ConstantBooleanType(true); - } - - return new BooleanType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - // For deep BooleanAnd chains in truthy context, flatten and - // process all arms at once to avoid O(N²) recursive - // filterByTruthyValue calls. - if ( - $context->true() - && self::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH - ) { - return $this->specifyTypesForFlattenedBooleanAnd($typeSpecifier, $scope, $expr, $context); - } - - $leftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); - $rightScope = $scope->filterByTruthyValue($expr->left); - $rightTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); - if ($context->true()) { - $types = $leftTypes->unionWith($rightTypes); - } else { - $types = $leftTypes->intersectWith($rightTypes); - $branchUnionAugment = $this->conditionalExpressionHolderHelper->buildBranchUnionAugment( - $leftTypes, - $rightTypes, - static fn (): MutatingScope => $scope->filterByFalseyValue($expr->left), - static fn (): MutatingScope => $rightScope->filterByFalseyValue($expr->right), - $types, - ); - if ($branchUnionAugment !== null) { - $types = $types->withDeferredAugment($branchUnionAugment); - } - } - if ($context->false()) { - // Consequent (holder) narrowings projected by each holder: these must be - // the genuine falsey narrowing of the arm. When that is empty, the arm - // has no sound falsey narrowing and must not contribute a consequent. - $leftHolderTypes = $leftTypes; - $rightHolderTypes = $rightTypes; - // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. - if ($context->truthy()) { - if ($leftHolderTypes->getSureTypes() === [] && $leftHolderTypes->getSureNotTypes() === []) { - $leftHolderTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createFalsey())->setRootExpr($expr); - } - if ($rightHolderTypes->getSureTypes() === [] && $rightHolderTypes->getSureNotTypes() === []) { - $rightHolderTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createFalsey())->setRootExpr($expr); - } - } - // Condition (antecedent) narrowings: when an arm has no falsey narrowing - // (e.g. isset() on an array dim fetch), derive the condition from the truthy - // narrowing by swapping sure/sureNot types. This swap is only sound for the - // antecedent — the holder-recipe evaluation inverts it back to the truthy - // narrowing. It must NOT feed the consequent: inverting a comparison's truthy - // narrowing (e.g. `$a === $b` narrowing `$a` to `$b`'s broad type) would - // over-narrow the consequent (see regression for `$x === $nonConstantString`). - // - // The inverted narrowing stands in for "this side is TRUE", so the - // side's truthy narrowing must be EQUIVALENT to its truth, not just - // implied by it. isset() qualifies: it is exactly the offset's - // non-nullness. A call like non-strict in_array($x, $a) does not - - // its truthy narrowing ($a non-empty) can hold while the call is - // false, and a holder conditioned on it would unsoundly narrow the - // other side (e.g. $x !== null && in_array($x, $a) pinning $x to - // null in a sibling branch where only $a !== [] is known). - $leftCondTypes = $leftHolderTypes; - $rightCondTypes = $rightHolderTypes; - if ($leftCondTypes->getSureTypes() === [] && $leftCondTypes->getSureNotTypes() === [] && $this->truthinessImpliedByTruthyNarrowing($expr->left)) { - $truthyLeftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyLeftTypes)) { - $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); - } - } - if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === [] && $this->truthinessImpliedByTruthyNarrowing($expr->right)) { - $truthyRightTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyRightTypes)) { - $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); - } - } - $result = $types->withoutConditionalExpressionHolders(); - $recipes = [ - $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftCondTypes, $rightHolderTypes, false, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightCondTypes, $leftHolderTypes, false, true, null, $expr->left), - $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftCondTypes, $rightHolderTypes, true, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightCondTypes, $leftHolderTypes, true, true, null, $expr->left), - ]; - return $result->setConditionalExpressionHolderRecipes(array_values(array_filter($recipes)))->setRootExpr($expr); - } - - return $types; - } - - public static function getBooleanExpressionDepth(Expr $expr): int - { - $depth = 0; - while ( - $expr instanceof BooleanOr || - $expr instanceof LogicalOr || - $expr instanceof BooleanAnd || - $expr instanceof LogicalAnd - ) { - $depth++; - $expr = $expr->left; - } - return $depth; - } - - /** - * Flatten a deep BooleanAnd chain into leaf expressions and process them - * without recursive filterByTruthyValue calls. - * - * @param BooleanAnd|LogicalAnd $expr - */ - private function specifyTypesForFlattenedBooleanAnd( - TypeSpecifier $typeSpecifier, - MutatingScope $scope, - Expr $expr, - TypeSpecifierContext $context, - ): SpecifiedTypes - { - $arms = []; - $current = $expr; - while ($current instanceof BooleanAnd || $current instanceof LogicalAnd) { - $arms[] = $current->right; - $current = $current->left; - } - $arms[] = $current; - $arms = array_reverse($arms); - - // Truthy: all arms are true → the same merge unionWith() does for the - // recursive path, applied to all arms at once - $armTypes = []; - foreach ($arms as $arm) { - $armTypes[] = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - } - - return SpecifiedTypes::unionAll($armTypes)->setRootExpr($expr); - } - - /** - * Whether the side's truthy narrowing is EQUIVALENT to the side being - * true - the requirement for using its inversion as a holder antecedent. - * isset() qualifies: it is exactly the offset's non-nullness. Anything - * else reaching the antecedent-swap fallback (e.g. a non-strict - * in_array() call, whose truthy narrowing only implies a non-empty - * haystack) must not stand in for its own truth. - */ - private function truthinessImpliedByTruthyNarrowing(Expr $side): bool - { - return $side instanceof Expr\Isset_; - } - - private function allExpressionsTrackable(SpecifiedTypes $types): bool - { - foreach ($types->getSureTypes() as [$expr]) { - if (!$this->isTrackableExpression($expr)) { - return false; - } - } - foreach ($types->getSureNotTypes() as [$expr]) { - if (!$this->isTrackableExpression($expr)) { - return false; - } - } - - return $types->getSureTypes() !== [] || $types->getSureNotTypes() !== []; - } - - private function isTrackableExpression(Expr $expr): bool - { - if ($expr instanceof Expr\Variable) { - return is_string($expr->name); - } - - return $expr instanceof Expr\PropertyFetch - || $expr instanceof Expr\ArrayDimFetch - || $expr instanceof Expr\StaticPropertyFetch; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -285,8 +65,49 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByTruthyValue($expr->right), - falseyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByFalseyValue($expr), + // && is truthy only when the right side was evaluated (on the left-truthy + // scope) and is itself truthy - that is exactly the right operand's truthy + // scope: it carries the left narrowing and the right's by-ref/side-effect + // definitions, and does not re-apply the left narrowing over a variable the + // right operand reassigned (bug-9400). + truthyScopeOverride: $rightResult->getTruthyScope(), + typeCallback: static function (bool $nativeTypesPromoted) use ($leftResult, $rightResult): Type { + $leftBooleanType = ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); + if ($leftBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + // the right side was processed on the left-truthy scope including + // the left's side effects (assignments, by-ref writes) - that + // captured scope is the evaluation point, no re-walk and no + // depth cap needed + $rightBooleanType = ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean(); + if ($rightBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + + if ( + $leftBooleanType->isTrue()->yes() + && $rightBooleanType->isTrue()->yes() + ) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, + $context, + $expr, + $expr->left, + static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $leftResult->getSpecifiedTypesForScope($scope, $ctx), + static fn (): MutatingScope => $leftResult->getTruthyScope(), + static fn (): MutatingScope => $leftResult->getFalseyScope(), + $expr->right, + static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $rightResult->getSpecifiedTypesForScope($scope, $ctx), + static fn (): MutatingScope => $rightResult->getFalseyScope(), + ), ); } diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 59cb5a986c1..6a35482d073 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -10,11 +10,10 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\BooleanType; @@ -28,7 +27,10 @@ final class BooleanNotHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -51,26 +53,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $exprBooleanType = $scope->getType($expr->expr)->toBoolean(); - if ($exprBooleanType instanceof ConstantBooleanType) { - return new ConstantBooleanType(!$exprBooleanType->getValue()); - } + typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult): Type { + $exprBooleanType = ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType())->toBoolean(); + if ($exprBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($exprBooleanType->isFalse()->yes()) { + return new ConstantBooleanType(true); + } - return new BooleanType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } + return new BooleanType(); + }, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $exprResult): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - return $typeSpecifier->specifyTypesInCondition($scope, $expr->expr, $context->negate())->setRootExpr($expr); + // The negated operand was processed above; compose its narrowing + // directly from its result rather than re-resolving the node. + return $exprResult->getSpecifiedTypes($context->negate(), $nativeTypesPromoted)->setRootExpr($expr); + }, + ); } } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index e59067bd29b..9642863e987 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -6,34 +6,23 @@ use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\BinaryOp\LogicalOr; use PhpParser\Node\Stmt; -use PHPStan\Analyser\DisjunctionHolderProjectionAugment; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\ConditionalExpressionHolderHelper; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\BooleanOrNode; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; -use function array_filter; -use function array_key_last; -use function array_keys; use function array_merge; -use function array_reverse; -use function array_values; -use function count; /** * @implements ExprHandler @@ -42,11 +31,8 @@ final class BooleanOrHandler implements ExprHandler { - private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; - public function __construct( - private NodeScopeResolver $nodeScopeResolver, - private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, private ExpressionResultFactory $expressionResultFactory, ) { @@ -57,203 +43,24 @@ public function supports(Expr $expr): bool return $expr instanceof BooleanOr || $expr instanceof LogicalOr; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - // For deep BooleanOr chains, resolve the boolean type by iterating the flattened arms while - // threading the falsey scope, instead of recursing into the left operand and re-narrowing the - // whole chain at each level - the latter is O(n^2) (and worse) in the number of arms. - if (BooleanAndHandler::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - return $this->resolveTypeForFlattenedBooleanOr($scope, $expr); - } - - $leftBooleanType = $scope->getType($expr->left)->toBoolean(); - if ($leftBooleanType->isTrue()->yes()) { - return new ConstantBooleanType(true); - } - - if (BooleanAndHandler::getBooleanExpressionDepth($expr->left) <= self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - $leftResult = $this->nodeScopeResolver->processExprNode(new Stmt\Expression($expr->left), $expr->left, $scope, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep()); - $rightBooleanType = $leftResult->getFalseyScope()->getType($expr->right)->toBoolean(); - } else { - $rightBooleanType = $scope->filterByFalseyValue($expr->left)->getType($expr->right)->toBoolean(); - } - - if ($rightBooleanType->isTrue()->yes()) { - return new ConstantBooleanType(true); - } - - if ( - $leftBooleanType->isFalse()->yes() - && $rightBooleanType->isFalse()->yes() - ) { - return new ConstantBooleanType(false); - } - - return new BooleanType(); - } - /** - * The whole chain is true if any arm is true (given the previous arms are false), false if every - * arm is false, and bool otherwise. Threading the falsey scope arm by arm keeps this O(n), matching - * the recursive resolveType() result without re-narrowing the whole left chain at each level. + * For `if ($a || $b)` truthy, expressions narrowed by stored conditional + * holders (e.g. `$a = $obj instanceof ClassA;` records "when `$a` is + * truthy, `$obj` is `ClassA`") need to be projected into the OR-truthy + * scope as the union of the per-arm narrowings. specifyTypesInCondition + * for each arm only looks at the boolean variable itself, so the held + * narrowing of `$obj` would otherwise be invisible until a later check + * pins one of the booleans down. * - * @param BooleanOr|LogicalOr $expr - */ - private function resolveTypeForFlattenedBooleanOr(MutatingScope $scope, Expr $expr): Type - { - $arms = []; - $current = $expr; - while ($current instanceof BooleanOr || $current instanceof LogicalOr) { - $arms[] = $current->right; - $current = $current->left; - } - $arms[] = $current; - $arms = array_reverse($arms); - - $allArmsAreFalse = true; - $armScope = $scope; - $lastArmKey = array_key_last($arms); - foreach ($arms as $key => $arm) { - $armBooleanType = $armScope->getType($arm)->toBoolean(); - if ($armBooleanType->isTrue()->yes()) { - return new ConstantBooleanType(true); - } - if (!$armBooleanType->isFalse()->yes()) { - $allArmsAreFalse = false; - } - if ($key === $lastArmKey) { - continue; - } - $armScope = $armScope->filterByFalseyValue($arm); - } - - return $allArmsAreFalse ? new ConstantBooleanType(false) : new BooleanType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - // For deep BooleanOr chains, flatten and process all arms at once - // to avoid O(n^2) recursive filterByFalseyValue calls - if (BooleanAndHandler::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - return $this->specifyTypesForFlattenedBooleanOr($typeSpecifier, $scope, $expr, $context); - } - - $leftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); - $rightScope = $scope->filterByFalseyValue($expr->left); - $rightTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); - - if ($context->true()) { - if ( - $scope->getType($expr->left)->toBoolean()->isFalse()->yes() - ) { - $types = $rightTypes; - } elseif ( - $scope->getType($expr->left)->toBoolean()->isTrue()->yes() - || $scope->getType($expr->right)->toBoolean()->isFalse()->yes() - ) { - $types = $leftTypes; - } else { - $types = $leftTypes->intersectWith($rightTypes); - $alternativeKeys = []; - foreach (array_keys($types->getAlternativeTypes()) as $alternativeExprString) { - $alternativeKeys[$alternativeExprString] = true; - } - $types = $types->withDeferredAugment(new DisjunctionHolderProjectionAugment( - $typeSpecifier, - static fn (): MutatingScope => $scope->filterByTruthyValue($expr->left), - $rightScope, - static fn (): MutatingScope => $rightScope->filterByTruthyValue($expr->right), - $alternativeKeys, - )); - $branchUnionAugment = $this->conditionalExpressionHolderHelper->buildBranchUnionAugment( - $leftTypes, - $rightTypes, - static fn (): MutatingScope => $scope->filterByTruthyValue($expr->left), - static fn (): MutatingScope => $rightScope->filterByTruthyValue($expr->right), - $types, - ); - if ($branchUnionAugment !== null) { - $types = $types->withDeferredAugment($branchUnionAugment); - } - } - } else { - $types = $leftTypes->unionWith($rightTypes); - } - - if ($context->true()) { - $result = $types->withoutConditionalExpressionHolders(); - $recipes = [ - $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftTypes, $rightTypes, false, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightTypes, $leftTypes, false, false, null, $expr->left), - $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftTypes, $rightTypes, true, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightTypes, $leftTypes, true, false, null, $expr->left), - ]; - return $result->setConditionalExpressionHolderRecipes(array_values(array_filter($recipes)))->setRootExpr($expr); - } - - return $types; - } - - /** - * Flatten a deep BooleanOr chain into leaf expressions and process them - * without recursive filterByFalseyValue calls. This reduces O(n^2) to O(n) - * for chains with many arms (e.g., 80+ === comparisons in ||). + * For each conditional-holder target $T: + * - resolve $T's type in the left-truthy and right-truthy filtered scopes + * - if both narrow $T strictly below the original, add `$T : leftT|rightT` + * as a sure type to the OR-truthy result + * + * The asymmetric case (one arm narrows, the other doesn't) is intentionally + * skipped: in the OR-truthy scope the arm that didn't narrow could still be + * the truthy one, so the sound result is the original (unnarrowed) type. */ - private function specifyTypesForFlattenedBooleanOr( - TypeSpecifier $typeSpecifier, - MutatingScope $scope, - BooleanOr|LogicalOr $expr, - TypeSpecifierContext $context, - ): SpecifiedTypes - { - // Collect all leaf expressions from the chain - $arms = []; - $current = $expr; - while ($current instanceof BooleanOr || $current instanceof LogicalOr) { - $arms[] = $current->right; - $current = $current->left; - } - $arms[] = $current; // leftmost leaf - $arms = array_reverse($arms); - - if ($context->false() || $context->falsey()) { - // Falsey: all arms are false → the same merge unionWith() does for - // the recursive path, applied to all arms at once - $armTypes = []; - foreach ($arms as $arm) { - $armTypes[] = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - } - - return SpecifiedTypes::unionAll($armTypes)->setRootExpr($expr); - } - - // Truthy: at least one arm is true → intersect all normalized SpecifiedTypes - $armSpecifiedTypes = []; - foreach ($arms as $arm) { - $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - $armSpecifiedTypes[] = $armTypes; - } - - $types = $armSpecifiedTypes[0]; - for ($i = 1; $i < count($armSpecifiedTypes); $i++) { - $types = $types->intersectWith($armSpecifiedTypes[$i]); - } - - $result = (new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ))->withAlternativeTypesOf($types); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } - - return $result->setRootExpr($expr); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -276,8 +83,51 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $leftResult->isAlwaysTerminating(), throwPoints: array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $leftMergedWithRightScope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $rightResult->getScope()->filterByFalseyValue($expr->right), + // || is falsey only when the right side was evaluated (on the left-falsey + // scope) and is itself falsey - that is exactly the right operand's falsey + // scope: it carries the left narrowing and the right's by-ref/side-effect + // definitions, and does not re-apply the left narrowing over a variable the + // right operand reassigned (bug-9400). + falseyScopeOverride: $rightResult->getFalseyScope(), + typeCallback: static function (bool $nativeTypesPromoted) use ($leftResult, $rightResult): Type { + $leftBooleanType = ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType())->toBoolean(); + if ($leftBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + // the right side was processed on the left-falsey scope including + // the left's side effects (assignments, by-ref writes) - that + // captured scope is the evaluation point, no re-walk and no + // depth cap needed + $rightBooleanType = ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean(); + if ($rightBooleanType->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + if ( + $leftBooleanType->isFalse()->yes() + && $rightBooleanType->isFalse()->yes() + ) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->booleanNarrowingHelper->specifyDisjunction( + $nodeScopeResolver, + $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, + $context, + $expr, + $expr->left, + static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $leftResult->getSpecifiedTypesForScope($scope, $ctx), + static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(), + static fn (): MutatingScope => $leftResult->getTruthyScope(), + static fn (): MutatingScope => $leftResult->getFalseyScope(), + $expr->right, + static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $rightResult->getSpecifiedTypesForScope($scope, $ctx), + static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(), + static fn (): MutatingScope => $rightResult->getTruthyScope(), + ), ); } diff --git a/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php new file mode 100644 index 00000000000..fc9afe10470 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -0,0 +1,255 @@ +setRootExpr($rootExpr); + // the right operand lives after the left is known true - its narrowing + // bases read from the left-truthy view, never the raw ask scope + $rightScope = $leftTruthyScope(); + $rightTypes = $rightTypesCallback($rightScope, $context)->setRootExpr($rootExpr); + if ($context->true()) { + $types = $leftTypes->unionWith($rightTypes); + } else { + $types = $leftTypes->intersectWith($rightTypes); + $branchUnionAugment = $this->conditionalExpressionHolderHelper->buildBranchUnionAugment($nodeScopeResolver, $leftTypes, $rightTypes, $leftFalseyScope, $rightFalseyScope, $types); + if ($branchUnionAugment !== null) { + $types = $types->withDeferredAugment($branchUnionAugment); + } + } + if ($context->false()) { + // Consequent (holder) narrowings projected by each holder: these must be + // the genuine falsey narrowing of the arm. When that is empty, the arm + // has no sound falsey narrowing and must not contribute a consequent. + $leftHolderTypes = $leftTypes; + $rightHolderTypes = $rightTypes; + // In a mixed truthy-and-false context, re-derive empty holders from the falsey narrowing. + if ($context->truthy()) { + if ($leftHolderTypes->getSureTypes() === [] && $leftHolderTypes->getSureNotTypes() === []) { + $leftHolderTypes = $leftTypesCallback($s, TypeSpecifierContext::createFalsey())->setRootExpr($rootExpr); + } + if ($rightHolderTypes->getSureTypes() === [] && $rightHolderTypes->getSureNotTypes() === []) { + $rightHolderTypes = $rightTypesCallback($rightScope, TypeSpecifierContext::createFalsey())->setRootExpr($rootExpr); + } + } + // Condition (antecedent) narrowings: when an arm has no falsey narrowing + // (e.g. isset() on an array dim fetch), derive the condition from the truthy + // narrowing by swapping sure/sureNot types. This swap is only sound for the + // antecedent — the holder-recipe evaluation inverts it back to the truthy + // narrowing. It must NOT feed the consequent: inverting a comparison's truthy + // narrowing (e.g. `$a === $b` narrowing `$a` to `$b`'s broad type) would + // over-narrow the consequent (see regression for `$x === $nonConstantString`). + $leftCondTypes = $leftHolderTypes; + $rightCondTypes = $rightHolderTypes; + if ($leftCondTypes->getSureTypes() === [] && $leftCondTypes->getSureNotTypes() === [] && $this->truthinessImpliedByTruthyNarrowing($leftExpr)) { + $truthyLeftTypes = $leftTypesCallback($s, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } + } + if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === [] && $this->truthinessImpliedByTruthyNarrowing($rightExpr)) { + $truthyRightTypes = $rightTypesCallback($rightScope, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + } + } + $result = $types->withoutConditionalExpressionHolders(); + $recipes = [ + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftCondTypes, $rightHolderTypes, false, true, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightCondTypes, $leftHolderTypes, false, true, null, $leftExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftCondTypes, $rightHolderTypes, true, true, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightCondTypes, $leftHolderTypes, true, true, null, $leftExpr), + ]; + return $result->setConditionalExpressionHolderRecipes(array_values(array_filter($recipes)))->setRootExpr($rootExpr); + } + + return $types; + } + + /** + * The disjunction narrowing - BooleanOr's specify semantics - composed + * from per-operand narrowing/type closures and branch scopes instead of + * the operands' ExpressionResults, so disjunctions without an AST node + * (the non-null narrowing of empty()) reuse it without synthesizing + * BooleanOr chains. + * + * The operand verdict callbacks take only the asked flavour: the decided + * checks read the operands' walk-position types (the results' own + * evaluation points), never the asking scope. + * + * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $leftTypesCallback + * @param callable(bool): Type $leftTypeCallback + * @param callable(MutatingScope, TypeSpecifierContext): SpecifiedTypes $rightTypesCallback + * @param callable(bool): Type $rightTypeCallback + * @param callable(): MutatingScope $leftTruthyScope + * @param callable(): MutatingScope $leftFalseyScope + * @param callable(): MutatingScope $rightTruthyScope + */ + public function specifyDisjunction( + NodeScopeResolver $nodeScopeResolver, + MutatingScope $s, + TypeSpecifierContext $context, + Expr $rootExpr, + Expr $leftExpr, + callable $leftTypesCallback, + callable $leftTypeCallback, + callable $leftTruthyScope, + callable $leftFalseyScope, + Expr $rightExpr, + callable $rightTypesCallback, + callable $rightTypeCallback, + callable $rightTruthyScope, + ): SpecifiedTypes + { + $leftTypes = $leftTypesCallback($s, $context)->setRootExpr($rootExpr); + $rightScope = $leftFalseyScope(); + $rightTypes = $rightTypesCallback($rightScope, $context)->setRootExpr($rootExpr); + + if ($context->true()) { + if ( + $leftTypeCallback($s->nativeTypesPromoted)->toBoolean()->isFalse()->yes() + ) { + $types = $rightTypes; + } elseif ( + $leftTypeCallback($s->nativeTypesPromoted)->toBoolean()->isTrue()->yes() + || $rightTypeCallback($s->nativeTypesPromoted)->toBoolean()->isFalse()->yes() + ) { + $types = $leftTypes; + } else { + $types = $leftTypes->intersectWith($rightTypes); + $alternativeKeys = []; + foreach (array_keys($types->getAlternativeTypes()) as $exprString) { + $alternativeKeys[$exprString] = true; + } + $types = $types->withDeferredAugment(new DisjunctionHolderProjectionAugment( + $nodeScopeResolver, + $this->defaultNarrowingHelper, + $leftTruthyScope, + $rightScope, + $rightTruthyScope, + $alternativeKeys, + )); + $branchUnionAugment = $this->conditionalExpressionHolderHelper->buildBranchUnionAugment($nodeScopeResolver, $leftTypes, $rightTypes, $leftTruthyScope, $rightTruthyScope, $types); + if ($branchUnionAugment !== null) { + $types = $types->withDeferredAugment($branchUnionAugment); + } + } + } else { + $types = $leftTypes->unionWith($rightTypes); + } + + if ($context->true()) { + $result = $types->withoutConditionalExpressionHolders(); + $recipes = [ + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftTypes, $rightTypes, false, false, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightTypes, $leftTypes, false, false, null, $leftExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($leftTypes, $rightTypes, true, false, $rightScope, $rightExpr), + $this->conditionalExpressionHolderHelper->buildConditionalHolderRecipe($rightTypes, $leftTypes, true, false, null, $leftExpr), + ]; + return $result->setConditionalExpressionHolderRecipes(array_values(array_filter($recipes)))->setRootExpr($rootExpr); + } + + return $types; + } + + /** + * Whether the side's truthy narrowing is EQUIVALENT to the side being + * true - the requirement for using its inversion as a holder antecedent. + * isset() qualifies: it is exactly the offset's non-nullness. Anything + * else reaching the antecedent-swap fallback (e.g. a non-strict + * in_array() call, whose truthy narrowing only implies a non-empty + * haystack) must not stand in for its own truth. + */ + private function truthinessImpliedByTruthyNarrowing(Expr $side): bool + { + return $side instanceof Expr\Isset_; + } + + private function allExpressionsTrackable(SpecifiedTypes $types): bool + { + // an alternative-form entry has no single condition type to track + if ($types->getAlternativeTypes() !== []) { + return false; + } + + foreach ($types->getSureTypes() as [$expr]) { + if (!$this->isTrackableExpression($expr)) { + return false; + } + } + foreach ($types->getSureNotTypes() as [$expr]) { + if (!$this->isTrackableExpression($expr)) { + return false; + } + } + + return $types->getSureTypes() !== [] || $types->getSureNotTypes() !== []; + } + + private function isTrackableExpression(Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return is_string($expr->name); + } + + return $expr instanceof Expr\PropertyFetch + || $expr instanceof Expr\ArrayDimFetch + || $expr instanceof Expr\StaticPropertyFetch; + } + +} diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 1e230049491..02543f14d84 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -10,22 +10,22 @@ use PHPStan\Analyser\ConditionalExpressionHolderRecipe; use PHPStan\Analyser\DisjunctionBranchUnionAugment; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\DependencyInjection\AutowiredService; use function is_string; /** - * Builds the conditional expression holders used to project narrowings of - * boolean operands (`&&`, `||`) into later scopes. Shared by BooleanAndHandler - * and BooleanOrHandler. + * Builds the conditional-expression-holder recipes used to project narrowings + * of boolean operands (`&&`, `||`) into later scopes. Shared by + * BooleanAndHandler and BooleanOrHandler. */ #[AutowiredService] final class ConditionalExpressionHolderHelper { public function __construct( - private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -44,6 +44,7 @@ public function __construct( * @param callable(): MutatingScope $rightFilteredScope */ public function buildBranchUnionAugment( + NodeScopeResolver $nodeScopeResolver, SpecifiedTypes $leftTypes, SpecifiedTypes $rightTypes, callable $leftFilteredScope, @@ -83,6 +84,12 @@ public function buildBranchUnionAugment( // union for this expression, deferred to the application point continue; } + // the exact either-branch merge already constrains this expression + // (an alternative-form entry) - the branch-scope union recovery + // would only add a weaker entry on top + if (isset($existingAlternativeTypes[$exprString])) { + continue; + } $leftScope ??= $leftFilteredScope(); $rightScope ??= $rightFilteredScope(); if (!$leftScope->hasExpressionType($targetExpr)->yes()) { @@ -96,8 +103,8 @@ public function buildBranchUnionAugment( // scopes - scope state answers without a walk $candidates[] = [ $targetExpr, - $leftScope->getType($targetExpr), - $rightScope->getType($targetExpr), + $nodeScopeResolver->readScopeStateOrSyntheticType($targetExpr, $leftScope), + $nodeScopeResolver->readScopeStateOrSyntheticType($targetExpr, $rightScope), ]; } @@ -105,7 +112,7 @@ public function buildBranchUnionAugment( return null; } - return new DisjunctionBranchUnionAugment($this->typeSpecifier, $candidates); + return new DisjunctionBranchUnionAugment($nodeScopeResolver, $this->defaultNarrowingHelper, $candidates); } /** @@ -153,14 +160,14 @@ public function buildConditionalHolderRecipe(SpecifiedTypes $conditionSpecifiedT continue; } - $conditionEntries[] = [$exprString, $expr, true, $type]; + $conditionEntries[] = [(string) $exprString, $expr, true, $type]; } foreach ($conditionSpecifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { if (!$this->isTrackableExpression($expr)) { continue; } - $conditionEntries[] = [$exprString, $expr, false, $type]; + $conditionEntries[] = [(string) $exprString, $expr, false, $type]; } if ($conditionEntries === []) { @@ -175,9 +182,9 @@ public function buildConditionalHolderRecipe(SpecifiedTypes $conditionSpecifiedT } $pinnedTargetType = !$expr instanceof Expr\Variable && $nonVariableTargetScope !== null - ? $nonVariableTargetScope->getType($expr) + ? $nonVariableTargetScope->getStateType($expr) : null; - $holderEntries[] = [$exprString, $expr, $type, $pinnedTargetType]; + $holderEntries[] = [(string) $exprString, $expr, $type, $pinnedTargetType]; } if ($holderEntries === []) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14908.php b/tests/PHPStan/Analyser/nsrt/bug-14908.php new file mode 100644 index 00000000000..d4cb4b38e18 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14908.php @@ -0,0 +1,44 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14908Nsrt; + +use function PHPStan\Testing\assertType; + +enum Grade { case One; case Two; case Three; } +enum Kind { case K1; case K2; case K3; } + +class Flags { public bool $flagA = false; } + +function run(Kind $kind, Grade $grade, Flags $flags, bool $extra, bool $cond): void +{ + $forced = false; + if ( + $grade !== Grade::Three + && $cond + && in_array($kind, [Kind::K1, Kind::K2], true) + && $flags->flagA === true + ) { + $forced = true; + } + + // Intermediate `if` narrowing ANOTHER value (`$extra === false`) in a disjunction. + // This is the ingredient that defeats the #14807 fix. + if ( + $forced === false + && ( + ($grade === Grade::One && $extra === false) + || ($cond && $grade !== Grade::Three) + ) + ) { + throw new \Exception(); + } + + // The narrowing from the first `if` must not leak here: skipping the first + // `if` says nothing about $flags->flagA or $kind on their own. + if ($grade !== Grade::Three) { + assertType('bool', $flags->flagA); + assertType('Bug14908Nsrt\Kind', $kind); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/conditional-expr-narrowing-second-operand.php b/tests/PHPStan/Analyser/nsrt/conditional-expr-narrowing-second-operand.php new file mode 100644 index 00000000000..f46fe58db1d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/conditional-expr-narrowing-second-operand.php @@ -0,0 +1,31 @@ +unsealed !== null && $b->unsealed !== null; + + // $bothDefinite as the first && operand + if ($bothDefinite && $other) { + assertType('array{int, int}', $a->unsealed); + assertType('array{int, int}', $b->unsealed); + } + + // $bothDefinite as the second && operand - regressed to array{int, int}|null + // because filterBySpecifiedTypes read the un-narrowed $bothDefinite via getType() + if ($other && $bothDefinite) { + assertType('array{int, int}', $a->unsealed); + assertType('array{int, int}', $b->unsealed); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/deep-boolean-and-chain.php b/tests/PHPStan/Analyser/nsrt/deep-boolean-and-chain.php new file mode 100644 index 00000000000..716f9b83ade --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/deep-boolean-and-chain.php @@ -0,0 +1,106 @@ += 8.1 += 8.1 -namespace Bug14908; +declare(strict_types = 1); -use function in_array; +namespace Bug14908; enum Grade { case One; case Two; case Three; } enum Kind { case K1; case K2; case K3; } diff --git a/tests/PHPStan/Rules/Functions/data/bug-13334.php b/tests/PHPStan/Rules/Functions/data/bug-13334.php new file mode 100644 index 00000000000..bc8966d1216 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-13334.php @@ -0,0 +1,50 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug13334; + +/** + * Example 1 - Inline + */ +$array = [-1, 0, 1, 5, '', 'hi', true, false, null]; +$key = array_rand($array, 1); +$value = $array[$key]; + +$strict = boolval(random_int(0,1)); + +$result = ( + is_string($value) + || ( + ! $strict + && boolval( + $value = ( + (! empty($value) && is_numeric($value)) + ? str_pad((string) $value, 6, '0', STR_PAD_LEFT) + : '' + ) + ) + ) + ) + && preg_match('/^0*[1-9]+[0-9]*$/', $value) === 1; + + +/** + * Example 2 - Using functions + */ +function ensureString(mixed $value): string +{ + return ((! empty($value) && is_numeric($value)) ? str_pad((string) $value, 6, '0', STR_PAD_LEFT) : ''); +} + +function isNonZeroPaddedString(mixed $value, bool $strict = false): bool +{ + return ( + is_string($value) + || ( + ! $strict + && boolval($value = ensureString($value)) + ) + ) + && preg_match('/^0*[1-9]+[0-9]*$/', $value) === 1; +} diff --git a/tests/bench/data/and-chain-resolve-type-blowup.php b/tests/bench/data/and-chain-resolve-type-blowup.php new file mode 100644 index 00000000000..a80e6aa3d0a --- /dev/null +++ b/tests/bench/data/and-chain-resolve-type-blowup.php @@ -0,0 +1,119 @@ + Date: Fri, 14 Aug 2026 19:11:33 +0200 Subject: [PATCH 07/32] Fold leaf handler type resolution into result callbacks Mechanical conversion of the handlers with no structural rework: resolveType() moves into the result's typeCallback and specifyTypes() into its specifyTypesCallback (default narrowing or the empty callback), reading operand types from the already-walked child results. Lexical context that does not depend on the asking scope (initializer contexts, class and function reflections) is hoisted out of the callbacks; ArrayHandler keys per-item results by spl_object_id so each item resolves at its own evaluation point. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/ExprHandler/ArrayHandler.php | 76 ++++--- .../ExprHandler/BitwiseNotHandler.php | 24 +- .../ExprHandler/CastStringHandler.php | 29 +-- .../ExprHandler/ClassConstFetchHandler.php | 52 +++-- src/Analyser/ExprHandler/CloneHandler.php | 25 +-- .../ExprHandler/ConstFetchHandler.php | 81 ++++--- .../ExprHandler/ErrorSuppressHandler.php | 23 +- src/Analyser/ExprHandler/EvalHandler.php | 21 +- src/Analyser/ExprHandler/ExitHandler.php | 21 +- src/Analyser/ExprHandler/IncludeHandler.php | 21 +- .../ExprHandler/InstanceofHandler.php | 208 ++++++++++-------- .../ExprHandler/InterpolatedStringHandler.php | 54 +++-- src/Analyser/ExprHandler/PrintHandler.php | 19 +- src/Analyser/ExprHandler/ScalarHandler.php | 20 +- src/Analyser/ExprHandler/ShellExecHandler.php | 19 +- src/Analyser/ExprHandler/ThrowHandler.php | 21 +- .../ExprHandler/UnaryMinusHandler.php | 25 +-- src/Analyser/ExprHandler/UnaryPlusHandler.php | 24 +- src/Analyser/ExprHandler/YieldFromHandler.php | 35 ++- src/Analyser/ExprHandler/YieldHandler.php | 48 ++-- 20 files changed, 399 insertions(+), 447 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index a8e58b932a4..e9dbdb5ec10 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -15,19 +15,19 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\LiteralArrayItem; use PHPStan\Node\LiteralArrayNode; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\CallableType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_key_exists; use function array_merge; use function count; +use function spl_object_id; /** * @implements ExprHandler @@ -48,34 +48,11 @@ public function supports(Expr $expr): bool return $expr instanceof Array_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $type = $this->initializerExprTypeResolver->getArrayType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); - - if ( - count($expr->items) === 2 - && isset($expr->items[0], $expr->items[1]) - && $type->isCallable()->maybe() - ) { - $isCallableCall = new FuncCall( - new FullyQualified('is_callable'), - [new Arg($expr)], - ); - if ( - $scope->hasExpressionType($isCallableCall)->yes() - && $scope->getType($isCallableCall)->isTrue()->yes() - ) { - $type = TypeCombinator::intersect($type, new CallableType()); - } - } - - return $type; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; $itemNodes = []; + $itemResults = []; $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -85,6 +62,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $scope, $storage); if ($arrayItem->key !== null) { $keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $scope, $storage, $nodeCallback, $context->enterDeep()); + $itemResults[spl_object_id($arrayItem->key)] = $keyResult; $hasYield = $hasYield || $keyResult->hasYield(); $throwPoints = array_merge($throwPoints, $keyResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $keyResult->getImpurePoints()); @@ -93,6 +71,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $valueResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->value, $scope, $storage, $nodeCallback, $context->enterDeep()); + $itemResults[spl_object_id($arrayItem->value)] = $valueResult; $hasYield = $hasYield || $valueResult->hasYield(); $throwPoints = array_merge($throwPoints, $valueResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $valueResult->getImpurePoints()); @@ -109,12 +88,45 @@ 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 { + $id = spl_object_id($inner); + if (array_key_exists($id, $itemResults)) { + return $nativeTypesPromoted + ? $itemResults[$id]->getNativeType() + : $itemResults[$id]->getType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + throw new ShouldNotHappenException(); + }); + + if ( + count($expr->items) === 2 + && isset($expr->items[0], $expr->items[1]) + && $type->isCallable()->maybe() + ) { + $isCallableCall = new FuncCall( + new FullyQualified('is_callable'), + [new Arg($expr)], + ); + 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 = TypeCombinator::intersect($type, new CallableType()); + } + } + + return $type; + }, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); } } diff --git a/src/Analyser/ExprHandler/BitwiseNotHandler.php b/src/Analyser/ExprHandler/BitwiseNotHandler.php index 4b6b6667823..68c71dff9d4 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -10,14 +10,13 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -30,6 +29,7 @@ final class BitwiseNotHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,17 +51,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } + typeCallback: fn (bool $nativeTypesPromoted) => $this->initializerExprTypeResolver->getBitwiseNotType($expr->expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getBitwiseNotType($expr->expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + throw new ShouldNotHappenException(); + }), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index bb13a0e2817..ac796042861 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -15,12 +15,11 @@ use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; use function array_merge; @@ -51,7 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $throwPoints = $exprResult->getThrowPoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope, $exprResult); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); @@ -65,21 +64,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getCastType($expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getCastType($expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new NotEqual($expr->expr, new String_('')), - $context, - )->setRootExpr($expr); + throw new ShouldNotHappenException(); + }), + specifyTypesCallback: static fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => ($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope)->obtainResultForNode( + new NotEqual($expr->expr, new String_('')), + )->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr), + ); } } diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index fe989f332c9..349b0df1de2 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -11,14 +11,13 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use function array_merge; @@ -33,6 +32,7 @@ final class ClassConstFetchHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -42,20 +42,6 @@ public function supports(Expr $expr): bool return $expr instanceof ClassConstFetch; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if (!$expr->name instanceof Identifier) { - return new MixedType(); - } - - return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection( - $expr->class, - $expr->name->name, - $scope->isInClass() ? $scope->getClassReflection() : null, - static fn (Expr $e): Type => $scope->getType($e), - ); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -64,6 +50,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -86,6 +73,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $nameResult->isAlwaysTerminating(); } + // the enclosing class is lexical - fixed at this node, identical on every + // (possibly narrowed) scope the callback may later be invoked with - so + // resolve it once here instead of reading it off the callback's scope. + $classReflection = $beforeScope->isInClass() ? $beforeScope->getClassReflection() : null; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -94,12 +86,28 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $classResult, $classReflection): Type { + if (!$expr->name instanceof Identifier) { + return new MixedType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection( + $expr->class, + $expr->name->name, + $classReflection, + // getClassConstFetchTypeByReflection only invokes this for $expr->class + // when it is an Expr, which is exactly when $classResult exists + static function (Expr $e) use ($classResult, $nativeTypesPromoted): Type { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + + return $nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType(); + }, + ); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index bc707347411..c890510777e 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -10,12 +10,10 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\Traverser\CloneTypeTraverser; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ObjectWithoutClassType; @@ -30,7 +28,10 @@ final class CloneHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -51,18 +52,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult): Type { + $cloneType = TypeCombinator::intersect(($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), new ObjectWithoutClassType()); + return TypeTraverser::map($cloneType, new CloneTypeTraverser()); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $cloneType = TypeCombinator::intersect($scope->getType($expr->expr), new ObjectWithoutClassType()); - return TypeTraverser::map($cloneType, new CloneTypeTraverser()); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/ConstFetchHandler.php b/src/Analyser/ExprHandler/ConstFetchHandler.php index 17f429322e0..86aa7eccb6c 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -12,11 +12,9 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Constant\ConstantBooleanType; @@ -35,6 +33,7 @@ final class ConstFetchHandler implements ExprHandler public function __construct( private ConstantResolver $constantResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -56,51 +55,45 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $constName = (string) $expr->name; - $loweredConstName = strtolower($constName); - if ($loweredConstName === 'true') { - return new ConstantBooleanType(true); - } elseif ($loweredConstName === 'false') { - return new ConstantBooleanType(false); - } elseif ($loweredConstName === 'null') { - return new NullType(); - } + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $scope): Type { + $constName = (string) $expr->name; + $loweredConstName = strtolower($constName); + if ($loweredConstName === 'true') { + return new ConstantBooleanType(true); + } elseif ($loweredConstName === 'false') { + return new ConstantBooleanType(false); + } elseif ($loweredConstName === 'null') { + return new NullType(); + } - $namespacedName = null; - if (!$expr->name->isFullyQualified() && $scope->getNamespace() !== null) { - $namespacedName = new FullyQualified([$scope->getNamespace(), $expr->name->toString()]); - } - $globalName = new FullyQualified($expr->name->toString()); + $namespacedName = null; + if (!$expr->name->isFullyQualified() && $scope->getNamespace() !== null) { + $namespacedName = new FullyQualified([$scope->getNamespace(), $expr->name->toString()]); + } + $globalName = new FullyQualified($expr->name->toString()); - foreach ([$namespacedName, $globalName] as $name) { - if ($name === null) { - continue; - } - $constFetch = new ConstFetch($name); - if ($scope->hasExpressionType($constFetch)->yes()) { - return $this->constantResolver->resolveConstantType( - $name->toString(), - $scope->expressionTypes[$scope->getNodeKey($constFetch)]->getType(), - ); - } - } + foreach ([$namespacedName, $globalName] as $name) { + if ($name === null) { + continue; + } + $constFetch = new ConstFetch($name); + if ($scope->hasExpressionType($constFetch)->yes()) { + return $this->constantResolver->resolveConstantType( + $name->toString(), + $scope->expressionTypes[$scope->getNodeKey($constFetch)]->getType(), + ); + } + } - $constantType = $this->constantResolver->resolveConstant($expr->name, $scope); - if ($constantType !== null) { - return $constantType; - } + $constantType = $this->constantResolver->resolveConstant($expr->name, $scope); + if ($constantType !== null) { + return $constantType; + } - return new ErrorType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return new ErrorType(); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index ca006ebcedb..5ab5282188f 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -12,9 +12,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; @@ -26,7 +24,9 @@ final class ErrorSuppressHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + ) { } @@ -37,29 +37,20 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context); return $this->expressionResultFactory->create( $exprResult->getScope(), - beforeScope: $scope, + beforeScope: $beforeScope, expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $exprResult->getTruthyScope(), - falseyScopeCallback: static fn (): MutatingScope => $exprResult->getFalseyScope(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), + specifyTypesCallback: static fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $exprResult->getSpecifiedTypes($context, $nativeTypesPromoted)->setRootExpr($expr), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyTypesInCondition($scope, $expr->expr, $context)->setRootExpr($expr); - } - } diff --git a/src/Analyser/ExprHandler/EvalHandler.php b/src/Analyser/ExprHandler/EvalHandler.php index 93cf1adc508..8d91fe55131 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -10,13 +10,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\MixedType; @@ -30,7 +28,10 @@ final class EvalHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -39,11 +40,6 @@ public function supports(Expr $expr): bool return $expr instanceof Eval_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new MixedType(); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -58,12 +54,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'eval', 'eval', true)]), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new MixedType(), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/ExitHandler.php b/src/Analyser/ExprHandler/ExitHandler.php index 7c1029c14e2..cdd85899af2 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -10,12 +10,10 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\NonAcceptingNeverType; @@ -29,7 +27,10 @@ final class ExitHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -65,17 +66,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: true, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new NonAcceptingNeverType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index e251cd84f44..f56de2f3b48 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -10,13 +10,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\MixedType; @@ -31,7 +29,10 @@ final class IncludeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -40,11 +41,6 @@ public function supports(Expr $expr): bool return $expr instanceof Include_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new MixedType(); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -60,12 +56,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, $identifier, $identifier, true)]), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new MixedType(), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/InstanceofHandler.php b/src/Analyser/ExprHandler/InstanceofHandler.php index b5288696912..731c60b634f 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -11,13 +11,13 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\MixedType; @@ -39,7 +39,10 @@ final class InstanceofHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -57,6 +60,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $exprResult->getImpurePoints(); $isAlwaysTerminating = $exprResult->isAlwaysTerminating(); $scope = $exprResult->getScope(); + $classResult = null; if (!$expr->class instanceof Name) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -66,6 +70,40 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $classResult->isAlwaysTerminating(); } + // When the class side is written as a Name (self / static / parent / a + // resolved class name) it is lexical - it does not vary with the scope the + // callbacks are later invoked on - so resolve the boolean-result class type + // and the narrowing type once here. $isInTrait is likewise lexical. + $isInTrait = $beforeScope->isInTrait(); + $nameClassType = null; + $nameNarrowType = null; + if ($expr->class instanceof Name) { + if (strtolower($expr->class->toString()) === 'static' && $beforeScope->isInClass()) { + $nameClassType = new StaticType($beforeScope->getClassReflection()); + } else { + $nameClassType = new ObjectType($beforeScope->resolveName($expr->class)); + } + + $className = (string) $expr->class; + $lowercasedClassName = strtolower($className); + if ($lowercasedClassName === 'self' && $beforeScope->isInClass()) { + $nameNarrowType = new ObjectType($beforeScope->getClassReflection()->getName()); + } elseif ($lowercasedClassName === 'static' && $beforeScope->isInClass()) { + $nameNarrowType = new StaticType($beforeScope->getClassReflection()); + } elseif ($lowercasedClassName === 'parent') { + if ( + $beforeScope->isInClass() + && $beforeScope->getClassReflection()->getParentClass() !== null + ) { + $nameNarrowType = new ObjectType($beforeScope->getClassReflection()->getParentClass()->getName()); + } else { + $nameNarrowType = new NonexistentParentClassType(); + } + } else { + $nameNarrowType = new ObjectType($className); + } + } + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -74,104 +112,92 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $expressionType = $scope->getType($expr->expr); - if ( - $scope->isInTrait() - && TypeUtils::findThisType($expressionType) !== null - ) { - return new BooleanType(); - } - if ($expressionType instanceof NeverType) { - return new ConstantBooleanType(false); - } - - $uncertainty = false; + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $exprResult, $classResult, $isInTrait, $nameClassType): Type { + $expressionType = $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + if ( + $isInTrait + && TypeUtils::findThisType($expressionType) !== null + ) { + return new BooleanType(); + } + if ($expressionType instanceof NeverType) { + return new ConstantBooleanType(false); + } - if ($expr->class instanceof Name) { - $unresolvedClassName = $expr->class->toString(); - if ( - strtolower($unresolvedClassName) === 'static' - && $scope->isInClass() - ) { - $classType = new StaticType($scope->getClassReflection()); - } else { - $className = $scope->resolveName($expr->class); - $classType = new ObjectType($className); - } - } else { - $result = $scope->getType($expr->class)->toObjectTypeForInstanceofCheck(); - $classType = $result->type; - $uncertainty = $result->uncertainty; - } + $uncertainty = false; - if ($classType->isSuperTypeOf(new MixedType())->yes()) { - return new BooleanType(); - } + if ($expr->class instanceof Name) { + if ($nameClassType === null) { + throw new ShouldNotHappenException(); + } + $classType = $nameClassType; + } else { + // this branch is only reached when $expr->class is an Expr, + // which is exactly when $classResult was set in processExpr + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $classNameType = $nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType(); + $result = $classNameType->toObjectTypeForInstanceofCheck(); + $classType = $result->type; + $uncertainty = $result->uncertainty; + } - $isSuperType = $classType->isSuperTypeOf($expressionType); + if ($classType->isSuperTypeOf(new MixedType())->yes()) { + return new BooleanType(); + } - if ($isSuperType->no()) { - return new ConstantBooleanType(false); - } elseif ($isSuperType->yes() && !$uncertainty) { - return new ConstantBooleanType(true); - } + $isSuperType = $classType->isSuperTypeOf($expressionType); - return new BooleanType(); - } + if ($isSuperType->no()) { + return new ConstantBooleanType(false); + } elseif ($isSuperType->yes() && !$uncertainty) { + return new ConstantBooleanType(true); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - $exprNode = $expr->expr; - if ($expr->class instanceof Name) { - $className = (string) $expr->class; - $lowercasedClassName = strtolower($className); - if ($lowercasedClassName === 'self' && $scope->isInClass()) { - $type = new ObjectType($scope->getClassReflection()->getName()); - } elseif ($lowercasedClassName === 'static' && $scope->isInClass()) { - $type = new StaticType($scope->getClassReflection()); - } elseif ($lowercasedClassName === 'parent') { - if ( - $scope->isInClass() - && $scope->getClassReflection()->getParentClass() !== null - ) { - $type = new ObjectType($scope->getClassReflection()->getParentClass()->getName()); - } else { - $type = new NonexistentParentClassType(); + return new BooleanType(); + }, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $exprResult, $classResult, $nameNarrowType, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $exprNode = $expr->expr; + if ($expr->class instanceof Name) { + if ($nameNarrowType === null) { + throw new ShouldNotHappenException(); + } + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $nameNarrowType, $context)->setRootExpr($expr); } - } else { - $type = new ObjectType($className); - } - return $typeSpecifier->create($exprNode, $type, $context, $scope)->setRootExpr($expr); - } - $result = $scope->getType($expr->class)->toObjectTypeForInstanceofCheck(); - $type = $result->type; - $uncertainty = $result->uncertainty; - - if (!$type->isSuperTypeOf(new MixedType())->yes()) { - if ($context->true()) { - $type = TypeCombinator::intersect( - $type, - new ObjectWithoutClassType(), - ); - return $typeSpecifier->create($exprNode, $type, $context, $scope)->setRootExpr($expr); - } elseif ($context->false() && !$uncertainty) { - $exprType = $scope->getType($expr->expr); - if (!$type->isSuperTypeOf($exprType)->yes()) { - return $typeSpecifier->create($exprNode, $type, $context, $scope)->setRootExpr($expr); + // this branch is only reached when $expr->class is an Expr, + // which is exactly when $classResult was set in processExpr + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $classNameType = $classResult->getTypeOnScope($s, $nativeTypesPromoted); + $result = $classNameType->toObjectTypeForInstanceofCheck(); + $type = $result->type; + $uncertainty = $result->uncertainty; + + if (!$type->isSuperTypeOf(new MixedType())->yes()) { + if ($context->true()) { + $type = TypeCombinator::intersect( + $type, + new ObjectWithoutClassType(), + ); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); + } elseif ($context->false() && !$uncertainty) { + $exprType = $exprResult->getTypeOnScope($s, $nativeTypesPromoted); + if (!$type->isSuperTypeOf($exprType)->yes()) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, $type, $context)->setRootExpr($expr); + } + } + } + if ($context->true()) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $exprNode, $exprResult, new ObjectWithoutClassType(), $context)->setRootExpr($exprNode); } - } - } - if ($context->true()) { - return $typeSpecifier->create($exprNode, new ObjectWithoutClassType(), $context, $scope)->setRootExpr($exprNode); - } - return (new SpecifiedTypes([], []))->setRootExpr($expr); + return (new SpecifiedTypes([], []))->setRootExpr($expr); + }, + ); } } diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index cdf2bdba301..84f6f77e44a 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -11,18 +11,17 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\Type; use function array_merge; +use function spl_object_id; /** * @implements ExprHandler @@ -35,6 +34,7 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,16 +51,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + /** @var array $partResults */ + $partResults = []; foreach ($expr->parts as $part) { if (!$part instanceof Expr) { continue; } $partResult = $nodeScopeResolver->processExprNode($stmt, $part, $scope, $storage, $nodeCallback, $context->enterDeep()); + $partResults[spl_object_id($part)] = $partResult; $hasYield = $hasYield || $partResult->hasYield(); $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope, $partResult); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); @@ -76,32 +79,27 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $partResults): Type { + $resultType = null; + foreach ($expr->parts as $part) { + if ($part instanceof InterpolatedStringPart) { + $partType = new ConstantStringType($part->value); + } else { + $partResult = $partResults[spl_object_id($part)]; + $partType = ($nativeTypesPromoted ? $partResult->getNativeType() : $partResult->getType())->toString(); + } + if ($resultType === null) { + $resultType = $partType; + continue; + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $resultType = null; - foreach ($expr->parts as $part) { - if ($part instanceof InterpolatedStringPart) { - $partType = new ConstantStringType($part->value); - } else { - $partType = $scope->getType($part)->toString(); - } - if ($resultType === null) { - $resultType = $partType; - continue; - } + $resultType = $this->initializerExprTypeResolver->resolveConcatType($resultType, $partType); + } - $resultType = $this->initializerExprTypeResolver->resolveConcatType($resultType, $partType); - } - - return $resultType ?? new ConstantStringType(''); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $resultType ?? new ConstantStringType(''); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index cd6a90aee17..f22dd0f8d61 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -10,13 +10,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Constant\ConstantIntegerType; @@ -33,6 +31,7 @@ final class PrintHandler implements ExprHandler public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -42,11 +41,6 @@ public function supports(Expr $expr): bool return $expr instanceof Print_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new ConstantIntegerType(1); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -54,7 +48,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = $exprResult->getThrowPoints(); $impurePoints = $exprResult->getImpurePoints(); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope, $exprResult); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); @@ -68,12 +62,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: array_merge($impurePoints, [new ImpurePoint($scope, $expr, 'print', 'print', true)]), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new ConstantIntegerType(1), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/ScalarHandler.php b/src/Analyser/ExprHandler/ScalarHandler.php index 9b4de986801..e2048b2a2d4 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -13,14 +13,10 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -43,6 +39,10 @@ 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); + return $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -51,17 +51,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: fn () => $this->initializerExprTypeResolver->getType($expr, $initializerExprContext), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getType($expr, InitializerExprContext::fromScope($scope)); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/ShellExecHandler.php b/src/Analyser/ExprHandler/ShellExecHandler.php index fa756b88650..fcc71c01eda 100644 --- a/src/Analyser/ExprHandler/ShellExecHandler.php +++ b/src/Analyser/ExprHandler/ShellExecHandler.php @@ -10,12 +10,10 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Constant\ConstantBooleanType; @@ -39,6 +37,7 @@ final class ShellExecHandler implements ExprHandler public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -64,7 +63,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($part, $scope, $partResult); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); @@ -80,17 +79,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: static fn (bool $nativeTypesPromoted): Type => TypeCombinator::union(new StringType(), new ConstantBooleanType(false), new NullType()), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return TypeCombinator::union(new StringType(), new ConstantBooleanType(false), new NullType()); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index e9b1ce7d37a..cea16377a81 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -10,12 +10,10 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\NonAcceptingNeverType; @@ -29,7 +27,10 @@ final class ThrowHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -50,17 +51,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: true, throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getType(), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return new NonAcceptingNeverType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/UnaryMinusHandler.php b/src/Analyser/ExprHandler/UnaryMinusHandler.php index ea67e7dabc4..9de17fb0f8c 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -10,11 +10,9 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; @@ -30,6 +28,7 @@ final class UnaryMinusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,17 +50,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } + typeCallback: fn (bool $nativeTypesPromoted) => $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult, $nodeScopeResolver, $scope): Type { + if ($e === $expr->expr) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getUnaryMinusType($expr->expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + // a synthetic node ($expr->expr * -1, derived for an IntegerRangeType + // operand) created inside getUnaryMinusType - priced on demand + return $nodeScopeResolver->processSyntheticOnDemand($e, $scope)->getTypeOnScope($scope, $nativeTypesPromoted); + }), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index 6ec1abe38fc..98111e662be 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -10,14 +10,13 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -30,6 +29,7 @@ final class UnaryPlusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,17 +51,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - ); - } + typeCallback: fn (bool $nativeTypesPromoted) => $this->initializerExprTypeResolver->getUnaryPlusType($expr->expr, static function (Expr $e) use ($nativeTypesPromoted, $expr, $exprResult): Type { + if ($e === $expr->expr) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getUnaryPlusType($expr->expr, static fn (Expr $expr): Type => $scope->getType($expr)); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + throw new ShouldNotHappenException(); + }), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 1aac8244af7..a7b8061c427 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -11,13 +11,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ErrorType; @@ -32,7 +30,10 @@ final class YieldFromHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -41,17 +42,6 @@ public function supports(Expr $expr): bool return $expr instanceof YieldFrom; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $yieldFromType = $scope->getType($expr->expr); - $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); - if ($generatorReturnType instanceof ErrorType) { - return new MixedType(); - } - - return $generatorReturnType; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -66,12 +56,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createImplicit($scope, $expr)]), impurePoints: array_merge($exprResult->getImpurePoints(), [new ImpurePoint($scope, $expr, 'yieldFrom', 'yield from', true)]), - ); - } + typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult): Type { + $yieldFromType = ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()); + $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); + if ($generatorReturnType instanceof ErrorType) { + return new MixedType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $generatorReturnType; + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 07abbc7e6ee..b7622910880 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -11,13 +11,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\ErrorType; @@ -32,7 +30,10 @@ final class YieldHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -41,22 +42,6 @@ public function supports(Expr $expr): bool return $expr instanceof Yield_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $functionReflection = $scope->getFunction(); - if ($functionReflection === null) { - return new MixedType(); - } - - $returnType = $functionReflection->getReturnType(); - $generatorSendType = $returnType->getTemplateType(Generator::class, 'TSend'); - if ($generatorSendType instanceof ErrorType) { - return new MixedType(); - } - - return $generatorSendType; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -88,6 +73,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $valueResult->isAlwaysTerminating(); } + // the enclosing function is lexical - the generator TSend type does not + // vary with the scope the callback is later invoked on - resolve it once here. + $functionReflection = $beforeScope->getFunction(); + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -96,12 +85,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + typeCallback: static function () use ($functionReflection): Type { + if ($functionReflection === null) { + return new MixedType(); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + $returnType = $functionReflection->getReturnType(); + $generatorSendType = $returnType->getTemplateType(Generator::class, 'TSend'); + if ($generatorSendType instanceof ErrorType) { + return new MixedType(); + } + + return $generatorSendType; + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } From a9c60e5477763babc07273ec9c50930bfa3b6a6d Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:34 +0200 Subject: [PATCH 08/32] Fabricate virtual expression results equal to walked ones VirtualExprResultHelper builds walk-free ExpressionResults for TypeExpr, NativeTypeExpr and UnsetOffsetExpr, so fabricated and walked results have the same shape by construction. The offset virtual handlers now actually walk their sub-expressions and read the results, and the PossiblyImpureCall marker node gets a dedicated handler. The four FirstClassCallable*Handlers existed only to carry resolveType()/specifyTypes() for the *CallableNode virtual nodes; with those interface methods moving into callbacks, the CallableNode handlers own their type directly and the extra handlers are deleted. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- .../FirstClassCallableFuncCallHandler.php | 81 ------------------ .../FirstClassCallableMethodCallHandler.php | 82 ------------------- .../FirstClassCallableNewHandler.php | 67 --------------- .../FirstClassCallableStaticCallHandler.php | 66 --------------- .../Helper/VirtualExprResultHelper.php | 65 +++++++++++++++ .../Virtual/AlwaysRememberedExprHandler.php | 37 ++++++--- .../Virtual/ExistingArrayDimFetchHandler.php | 24 ++---- .../Virtual/FunctionCallableNodeHandler.php | 47 ++++++++--- .../InstantiationCallableNodeHandler.php | 27 +++--- .../ExprHandler/Virtual/IssetExprHandler.php | 37 ++++----- .../Virtual/MethodCallableNodeHandler.php | 43 +++++++--- .../Virtual/NativeTypeExprHandler.php | 33 +------- .../Virtual/PossiblyImpureCallExprHandler.php | 58 +++++++++++++ .../SetExistingOffsetValueTypeExprHandler.php | 33 ++++---- .../Virtual/SetOffsetValueTypeExprHandler.php | 34 ++++---- .../StaticMethodCallableNodeHandler.php | 27 +++--- .../ExprHandler/Virtual/TypeExprHandler.php | 30 +------ .../Virtual/UnsetOffsetExprHandler.php | 41 +++------- 18 files changed, 302 insertions(+), 530 deletions(-) delete mode 100644 src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php delete mode 100644 src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php delete mode 100644 src/Analyser/ExprHandler/FirstClassCallableNewHandler.php delete mode 100644 src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php create mode 100644 src/Analyser/ExprHandler/Helper/VirtualExprResultHelper.php create mode 100644 src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php diff --git a/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php deleted file mode 100644 index 266996eaeb2..00000000000 --- a/src/Analyser/ExprHandler/FirstClassCallableFuncCallHandler.php +++ /dev/null @@ -1,81 +0,0 @@ - - */ -#[AutowiredService] -final class FirstClassCallableFuncCallHandler implements ExprHandler -{ - - public function __construct( - private InitializerExprTypeResolver $initializerExprTypeResolver, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof FuncCall && $expr->isFirstClassCallable(); - } - - public function processExpr( - NodeScopeResolver $nodeScopeResolver, - Stmt $stmt, - Expr $expr, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - ExpressionContext $context, - ): ExpressionResult - { - // handled in NodeScopeResolver before ExprHandlers are called - throw new ShouldNotHappenException(); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->name instanceof Expr) { - $callableType = $scope->getType($expr->name); - if (!$callableType->isCallable()->yes()) { - return new ObjectType(Closure::class); - } - - return $this->initializerExprTypeResolver->createFirstClassCallable( - null, - $callableType->getCallableParametersAcceptors($scope), - $scope->nativeTypesPromoted, - ); - } - - return $this->initializerExprTypeResolver->getFirstClassCallableType($expr, InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - -} diff --git a/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php deleted file mode 100644 index 1cafdd5b120..00000000000 --- a/src/Analyser/ExprHandler/FirstClassCallableMethodCallHandler.php +++ /dev/null @@ -1,82 +0,0 @@ - - */ -#[AutowiredService] -final class FirstClassCallableMethodCallHandler implements ExprHandler -{ - - public function __construct( - private InitializerExprTypeResolver $initializerExprTypeResolver, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof MethodCall && $expr->isFirstClassCallable(); - } - - public function processExpr( - NodeScopeResolver $nodeScopeResolver, - Stmt $stmt, - Expr $expr, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - ExpressionContext $context, - ): ExpressionResult - { - // handled in NodeScopeResolver before ExprHandlers are called - throw new ShouldNotHappenException(); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if (!$expr->name instanceof Identifier) { - return new ObjectType(Closure::class); - } - - $varType = $scope->getType($expr->var); - $method = $scope->getMethodReflection($varType, $expr->name->toString()); - if ($method === null) { - return new ObjectType(Closure::class); - } - - return $this->initializerExprTypeResolver->createFirstClassCallable( - $method, - $method->getVariants(), - $scope->nativeTypesPromoted, - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - -} diff --git a/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php b/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php deleted file mode 100644 index e158a8cc7b8..00000000000 --- a/src/Analyser/ExprHandler/FirstClassCallableNewHandler.php +++ /dev/null @@ -1,67 +0,0 @@ - - */ -#[AutowiredService] -final class FirstClassCallableNewHandler implements ExprHandler -{ - - public function __construct( - private InitializerExprTypeResolver $initializerExprTypeResolver, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof New_ && !$expr->class instanceof Class_ && $expr->isFirstClassCallable(); - } - - public function processExpr( - NodeScopeResolver $nodeScopeResolver, - Stmt $stmt, - Expr $expr, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - ExpressionContext $context, - ): ExpressionResult - { - // handled in NodeScopeResolver before ExprHandlers are called - throw new ShouldNotHappenException(); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getFirstClassCallableType($expr, InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - -} diff --git a/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php b/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php deleted file mode 100644 index 4d3519cf944..00000000000 --- a/src/Analyser/ExprHandler/FirstClassCallableStaticCallHandler.php +++ /dev/null @@ -1,66 +0,0 @@ - - */ -#[AutowiredService] -final class FirstClassCallableStaticCallHandler implements ExprHandler -{ - - public function __construct( - private InitializerExprTypeResolver $initializerExprTypeResolver, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof StaticCall && $expr->isFirstClassCallable(); - } - - public function processExpr( - NodeScopeResolver $nodeScopeResolver, - Stmt $stmt, - Expr $expr, - MutatingScope $scope, - ExpressionResultStorage $storage, - callable $nodeCallback, - ExpressionContext $context, - ): ExpressionResult - { - // handled in NodeScopeResolver before ExprHandlers are called - throw new ShouldNotHappenException(); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->initializerExprTypeResolver->getFirstClassCallableType($expr, InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - -} diff --git a/src/Analyser/ExprHandler/Helper/VirtualExprResultHelper.php b/src/Analyser/ExprHandler/Helper/VirtualExprResultHelper.php new file mode 100644 index 00000000000..6f5eb174da1 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/VirtualExprResultHelper.php @@ -0,0 +1,65 @@ +expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $expr instanceof TypeExpr + ? static fn (bool $nativeTypesPromoted): Type => $expr->getExprType() + : static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeType() : $expr->getPhpDocType(), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + + public function createUnsetOffsetExprResult(MutatingScope $scope, UnsetOffsetExpr $expr, ExpressionResult $varResult, ExpressionResult $dimResult): ExpressionResult + { + return $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->unsetOffset($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + } + +} diff --git a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php index 99d6d9925e8..4fda86a3730 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -9,11 +9,10 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; @@ -26,7 +25,10 @@ final class AlwaysRememberedExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -58,17 +60,26 @@ public function processExpr( isAlwaysTerminating: $innerResult->isAlwaysTerminating(), throwPoints: $innerResult->getThrowPoints(), impurePoints: $innerResult->getImpurePoints(), - ); - } + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(), + // Narrowing by the remembered wrapper is narrowing by the inner + // expression (TypeSpecifier unwrapped it and specified both keys); + // the wrapper node itself keeps the default truthy/falsey entry. + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)->unionWith( + $innerResult->getSpecifiedTypes($context, $nativeTypesPromoted), + ), + // A type constraint on the remembered wrapper constrains both the wrapper + // node (under its __phpstanRemembered(...) key) and the inner expression - + // what TypeSpecifier::create() recovered by fanning the AlwaysRememberedExpr + // out into wrapper + inner. The inner composes through its own child result; + // raw-Expr callers still go through create()->createForExpr. + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $innerExpr, $innerResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $innerExpr, $innerResult, $type, $context), + ); + }, + ); } } diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index 411d2ee8d65..82c3a4a4ca7 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -11,10 +11,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Type\Type; @@ -37,8 +34,13 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr + // virtual node: callers only read the type, computed lazily by the + // typeCallback. The plain array dim fetch is processed here (its real + // leaves are already stored by on-demand time) so the typeCallback reads + // its ExpressionResult instead of Scope::getType(). A null + // specifyTypesCallback falls back to default narrowing in TypeSpecifier, + // matching the old specifyDefaultTypes(). + $arrayDimFetchResult = $nodeScopeResolver->processExprNode($stmt, new Expr\ArrayDimFetch($expr->getVar(), $expr->getDim()), $scope, $storage, $nodeCallback, $context); return $this->expressionResultFactory->create( $scope, @@ -48,17 +50,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $arrayDimFetchResult->getNativeType() : $arrayDimFetchResult->getType()), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType(new Expr\ArrayDimFetch($expr->getVar(), $expr->getDim())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php index e56509e627e..b15dd374011 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use Closure; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -9,15 +10,16 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\FunctionCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; /** @@ -27,7 +29,11 @@ final class FunctionCallableNodeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) { } @@ -43,6 +49,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $hasYield = false; $isAlwaysTerminating = false; + $nameResult = null; if ($expr->getName() instanceof Expr) { $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->getName(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $scope = $nameResult->getScope(); @@ -60,19 +67,33 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->resolveType($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $nameResult), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type + private function resolveType(MutatingScope $scope, FunctionCallableNode $expr, ?ExpressionResult $nameResult): Type { - // in practice the type of the first-class callable is resolved - // by FirstClassCallableFuncCallHandler - return new MixedType(); - } + $originalNode = $expr->getOriginalNode(); + if ($originalNode->name instanceof Expr) { + // $originalNode->name is the same node as $expr->getName(), processed + // in processExpr exactly in this branch - read its ExpressionResult + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + $callableType = $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + if (!$callableType->isCallable()->yes()) { + return new ObjectType(Closure::class); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->initializerExprTypeResolver->createFirstClassCallable( + null, + $callableType->getCallableParametersAcceptors($scope), + $scope->nativeTypesPromoted, + ); + } + + return $this->initializerExprTypeResolver->getFirstClassCallableType($originalNode, InitializerExprContext::fromScope($scope), $scope->nativeTypesPromoted); } } diff --git a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index 937b6618d85..492d3f3bfaa 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -9,15 +9,14 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\InstantiationCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; /** @@ -27,7 +26,11 @@ final class InstantiationCallableNodeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) { } @@ -60,19 +63,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($beforeScope), $nativeTypesPromoted), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - // in practice the type of the first-class callable is resolved - // by FirstClassCallableNewHandler - return new MixedType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php index 8709610e6c0..c1afa08d7b0 100644 --- a/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php @@ -9,23 +9,21 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\IssetExpr; use PHPStan\Type\Type; /** - * IssetExpr is a certainty marker wrapped around an isset-tested expression so - * a type specification can reduce that expression's existence certainty (to - * maybe / unset) instead of narrowing its type. The specifications carrying it - * read only its certainty, never its type - so the marker just reports its - * inner expression's type, which lets it be priced like any other node rather - * than being a special case in the resolution paths. + * IssetExpr is a certainty marker IssetHandler wraps around the isset-tested + * expression so a type specification can reduce that expression's existence + * certainty (to maybe / unset) instead of narrowing its type. The specifications + * carrying it read only its certainty, never its type - so the marker just + * reports its inner expression's type, which lets it be priced like any other + * node rather than being a special case in the resolution paths. * * @implements ExprHandler */ @@ -33,7 +31,10 @@ final class IssetExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -44,8 +45,8 @@ 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 virtual node handler - the caller will only be interested in the - // type; the inner expr is not processed, its type is just reported + // because this is a virtual node handler, the caller will only be interested + // in the type - we don't process the inner expr, just report its type return $this->expressionResultFactory->create( $scope, @@ -55,17 +56,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nodeScopeResolver->readScopeStateOrSyntheticType($expr->getExpr(), $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->getExpr()); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index 28492541bce..19a1bc47f86 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -2,22 +2,23 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use Closure; use PhpParser\Node\Expr; +use PhpParser\Node\Identifier; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\MethodCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; use function array_merge; @@ -28,7 +29,11 @@ final class MethodCallableNodeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) { } @@ -63,19 +68,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->resolveType($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $varResult), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type + private function resolveType(MutatingScope $scope, MethodCallableNode $expr, ExpressionResult $varResult): Type { - // in practice the type of the first-class callable is resolved - // by FirstClassCallableMethodCallHandler - return new MixedType(); - } + $originalNode = $expr->getOriginalNode(); + if (!$originalNode->name instanceof Identifier) { + return new ObjectType(Closure::class); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + // $originalNode->var is the same node as $expr->getVar(), processed in + // processExpr - read its ExpressionResult instead of Scope::getType() + $varType = $varResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $method = $scope->getMethodReflection($varType, $originalNode->name->toString()); + if ($method === null) { + return new ObjectType(Closure::class); + } + + return $this->initializerExprTypeResolver->createFirstClassCallable( + $method, + $method->getVariants(), + $scope->nativeTypesPromoted, + ); } } diff --git a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php index 852d00b14cd..4ffe6d07df1 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -6,18 +6,13 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; -use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\NativeTypeExpr; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -26,7 +21,7 @@ final class NativeTypeExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct(private VirtualExprResultHelper $virtualExprResultHelper) { } @@ -39,29 +34,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($scope->nativeTypesPromoted) { - return $expr->getNativeType(); - } - return $expr->getPhpDocType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->virtualExprResultHelper->createTypeExprResult($scope, $expr); } } diff --git a/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php new file mode 100644 index 00000000000..bb285ed0827 --- /dev/null +++ b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php @@ -0,0 +1,58 @@ + + */ +#[AutowiredService] +final class PossiblyImpureCallExprHandler implements ExprHandler +{ + + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + + public function supports(Expr $expr): bool + { + return $expr instanceof PossiblyImpureCallExpr; + } + + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + return $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nodeScopeResolver->readScopeStateOrSyntheticType($expr->callExpr, $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + +} diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index 31f221a9e32..e288c0fe8b9 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -11,10 +11,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; use PHPStan\Type\Type; @@ -37,8 +34,15 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr + // virtual node: callers only read the type, computed lazily by the + // typeCallback. The (synthetic) sub-expressions are processed here - by + // on-demand time their real leaves are already stored, so this reads them + // back; the typeCallback then reads the ExpressionResults instead of + // Scope::getType(). A null specifyTypesCallback falls back to default + // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, $context); + $dimResult = $nodeScopeResolver->processExprNode($stmt, $expr->getDim(), $scope, $storage, $nodeCallback, $context); + $valueResult = $nodeScopeResolver->processExprNode($stmt, $expr->getValue(), $scope, $storage, $nodeCallback, $context); return $this->expressionResultFactory->create( $scope, @@ -48,21 +52,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->setExistingOffsetValueType( + ($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()), + ($nativeTypesPromoted ? $valueResult->getNativeType() : $valueResult->getType()), + ), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->getVar()); - return $varType->setExistingOffsetValueType( - $scope->getType($expr->getDim()), - $scope->getType($expr->getValue()), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php index ee9201224ee..e67c7af9b5f 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -11,10 +11,7 @@ use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\SetOffsetValueTypeExpr; use PHPStan\Type\Type; @@ -37,8 +34,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 { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr + // virtual node: callers only read the type, computed lazily by the + // typeCallback. The (synthetic) sub-expressions are processed here so the + // typeCallback reads their ExpressionResults instead of Scope::getType(). + // A null specifyTypesCallback falls back to default narrowing in + // TypeSpecifier, matching the old specifyDefaultTypes(). + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, $context); + $dimResult = $expr->getDim() !== null + ? $nodeScopeResolver->processExprNode($stmt, $expr->getDim(), $scope, $storage, $nodeCallback, $context) + : null; + $valueResult = $nodeScopeResolver->processExprNode($stmt, $expr->getValue(), $scope, $storage, $nodeCallback, $context); return $this->expressionResultFactory->create( $scope, @@ -48,21 +53,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType())->setOffsetValueType( + $dimResult !== null ? ($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()) : null, + ($nativeTypesPromoted ? $valueResult->getNativeType() : $valueResult->getType()), + ), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->getVar()); - return $varType->setOffsetValueType( - $expr->getDim() !== null ? $scope->getType($expr->getDim()) : null, - $scope->getType($expr->getValue()), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php index b12d7e120e5..b3225d5336a 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -9,15 +9,14 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\StaticMethodCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; use function array_merge; @@ -28,7 +27,11 @@ final class StaticMethodCallableNodeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) { } @@ -69,19 +72,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($beforeScope), $nativeTypesPromoted), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - // in practice the type of the first-class callable is resolved - // by FirstClassCallableStaticCallHandler - return new MixedType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php index 6ca636fe081..cd6e86c1a6c 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -6,18 +6,13 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; -use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -26,7 +21,7 @@ final class TypeExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct(private VirtualExprResultHelper $virtualExprResultHelper) { } @@ -39,26 +34,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { // because this is a virtual node handler, the caller will only be interested in the type // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $expr->getExprType(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->virtualExprResultHelper->createTypeExprResult($scope, $expr); } } diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index d414e648fd2..af2a3b98e59 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -6,18 +6,13 @@ use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; -use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\UnsetOffsetExpr; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -26,7 +21,7 @@ final class UnsetOffsetExprHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct(private VirtualExprResultHelper $virtualExprResultHelper) { } @@ -37,28 +32,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 { - // because this is a virtual node handler, the caller will only be interested in the type - // we don't need to process the inner expr - - return $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->getVar())->unsetOffset($scope->getType($expr->getDim())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + // virtual node: callers only read the type, computed lazily by the + // typeCallback. The (synthetic) sub-expressions are processed here - by + // on-demand time their real leaves are already stored, so this reads them + // back; the typeCallback then reads the ExpressionResults instead of + // Scope::getType(). A null specifyTypesCallback falls back to default + // narrowing in TypeSpecifier, matching the old specifyDefaultTypes(). + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, $context); + $dimResult = $nodeScopeResolver->processExprNode($stmt, $expr->getDim(), $scope, $storage, $nodeCallback, $context); + + return $this->virtualExprResultHelper->createUnsetOffsetExprResult($scope, $expr, $varResult, $dimResult); } } From d1adbe93c1ede6cbcd5a5edea74185c8f89464ff Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:34 +0200 Subject: [PATCH 09/32] Rework the call handlers around preliminary results and ArgsResult processArgs() captures every argument's ExpressionResult into ArgsResult together with the acceptor resolved after all arguments are walked, so the call handlers select the acceptor from argument results instead of pre-selecting it before the walk. FuncCall, MethodCall, StaticCall and New share the preliminary-result pattern: a result carrying the callbacks is stored before throw points are computed and finalize()d afterwards, because resolving the return type for throw points would otherwise recurse into the unfinished call. Dynamic return type extensions run inside a primed storage (DynamicReturnTypeStoragePrimer) so Scope::getType() on an argument inside an extension hits the stored result instead of re-walking the argument. MethodCallReturnTypeHelper accepts the pre-resolved acceptor and the ArgsResult; the implicit __toString and method throw point helpers take the caller's computed result and return type instead of re-pricing the receiver. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/ArgsResult.php | 19 +- src/Analyser/ExprHandler/FuncCallHandler.php | 485 +++++++++++++----- .../Helper/DynamicReturnTypeStoragePrimer.php | 70 +++ .../Helper/ImplicitToStringCallHelper.php | 30 +- .../Helper/MethodCallReturnTypeHelper.php | 116 +++-- .../Helper/MethodThrowPointHelper.php | 16 +- .../ExprHandler/MethodCallHandler.php | 351 ++++++++++--- src/Analyser/ExprHandler/NewHandler.php | 157 ++++-- src/Analyser/ExprHandler/PipeHandler.php | 60 +-- .../ExprHandler/StaticCallHandler.php | 408 ++++++++++----- .../nsrt/arrow-function-call-arg-type.php | 18 + .../nsrt/precise-scope-select-from-args.php | 28 + 12 files changed, 1315 insertions(+), 443 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/DynamicReturnTypeStoragePrimer.php create mode 100644 tests/PHPStan/Analyser/nsrt/arrow-function-call-arg-type.php create mode 100644 tests/PHPStan/Analyser/nsrt/precise-scope-select-from-args.php diff --git a/src/Analyser/ArgsResult.php b/src/Analyser/ArgsResult.php index 4ee94b87513..b53d7325cd7 100644 --- a/src/Analyser/ArgsResult.php +++ b/src/Analyser/ArgsResult.php @@ -2,26 +2,41 @@ namespace PHPStan\Analyser; +use PhpParser\Node\Expr; use PHPStan\Reflection\ParametersAcceptor; +use function spl_object_id; /** * Result of NodeScopeResolver::processArgs(): the scope/throw/impure state after * processing all arguments (wrapped ExpressionResult) plus the ParametersAcceptor * resolved from the arg types gathered on the arg-to-arg evolving scope. The * resolved acceptor is type-driven (selectFromTypes) so its generics are resolved - * against the actual argument types - callers wire it into the call's return - * type. Null when the call had no variants (dynamic callee). + * against the actual argument types - callers wire it into the call expression's + * stored return type. Null when the call had no variants (dynamic callee). */ final class ArgsResult { + /** + * @param array $argResults keyed by spl_object_id of each argument's value expression + */ public function __construct( private ExpressionResult $expressionResult, private ?ParametersAcceptor $resolvedParametersAcceptor, + private array $argResults = [], ) { } + /** + * The already-processed ExpressionResult of a call argument's value expression, + * so callers read its type via the result instead of re-asking the scope. + */ + public function getArgResult(Expr $argValue): ?ExpressionResult + { + return $this->argResults[spl_object_id($argValue)] ?? null; + } + public function getScope(): MutatingScope { return $this->expressionResult->getScope(); diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 689638715c3..abcf72db241 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -13,15 +13,17 @@ use PhpParser\Node\Name; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\OutputBufferHelper; -use PHPStan\Analyser\ExprHandler\Helper\VoidToNullTypeTransformer; use PHPStan\Analyser\GatheringNodeCallback; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -49,6 +51,7 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\Comparison\ImpossibleCheckTypeHelper; +use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasPropertyType; @@ -77,6 +80,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use Throwable; +use WeakReference; use function array_filter; use function array_map; use function array_merge; @@ -99,7 +103,6 @@ final class FuncCallHandler implements ExprHandler * @param ExtensionsCollection $dynamicFunctionThrowTypeExtensions */ public function __construct( - private EarlyTerminatingCallHelper $earlyTerminatingCallHelper, private ReflectionProvider $reflectionProvider, #[AutowiredExtensions(of: DynamicFunctionThrowTypeExtension::class)] private ExtensionsCollection $dynamicFunctionThrowTypeExtensions, @@ -109,6 +112,10 @@ public function __construct( #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, + private DynamicReturnTypeStoragePrimer $storagePrimer, private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, ) { @@ -126,12 +133,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $variants = []; $namedArgumentsVariants = null; $functionReflection = null; + $nameResult = null; $throwPoints = []; $impurePoints = []; - $isAlwaysTerminating = false; + // A call configured as early-terminating never returns: give it an explicit + // never so the statement's exit point follows from the result type, instead of + // NodeScopeResolver re-deriving it via Scope::getType(). + $isEarlyTerminating = $expr->name instanceof Name + && $this->earlyTerminatingHelper->isEarlyTerminatingFunctionCall($expr->name->toString()); + $isAlwaysTerminating = $isEarlyTerminating; if ($expr->name instanceof Expr) { - // process the dynamic callee name first, then consume its type rather - // than reading it before processExprNode() stores its result + // process the dynamic callee name first, then consume its type (single-pass + // inside-out) rather than reading it before processExprNode() stores it $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); $nameType = $nameResult->getType(); if (!$nameType->isCallable()->no()) { @@ -193,7 +206,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $functionReflection->getName() === 'clone' && count($normalizedExpr->getArgs()) === 2 ) { - $clonePropertiesArgType = $scope->getType($normalizedExpr->getArgs()[1]->value); + // process the clone arguments as reads so the cloned object and the + // properties array resolve from stored results instead of unprocessed + // nodes; processArgs() below processes them again as clone()'s arguments, + // so the NoopNodeCallback here avoids duplicate node-callbacks. + $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $clonePropertiesArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[1]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $clonePropertiesArgType = $clonePropertiesArgResult->getType(); $cloneExpr = new TypeExpr($scope->getType(new Expr\Clone_($normalizedExpr->getArgs()[0]->value))); $clonePropertiesArgTypeConstantArrays = $clonePropertiesArgType->getConstantArrays(); foreach ($clonePropertiesArgTypeConstantArrays as $clonePropertiesArgTypeConstantArray) { @@ -251,8 +270,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($firstParamName !== null) { $arrayWalkArrayArg = $normalizedExpr->getArgs()[0]->value; - $arrayWalkOriginalArrayType = $scope->getType($arrayWalkArrayArg); - $arrayWalkOriginalArrayNativeType = $scope->getNativeType($arrayWalkArrayArg); $nodeCallbackForArgs = new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($callbackArg, $firstParamName, &$arrayWalkValueTypes): void { if (!($node instanceof ClosureReturnStatementsNode) || $node->getClosureExpr() !== $callbackArg) { @@ -294,6 +311,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context); $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); + $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $hasYield = $argsResult->hasYield(); $throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); @@ -302,7 +320,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($functionReflection !== null) { // created after the args were processed - the side-effect flip // parameters (print_r's $return, ...) read an argument's type, which - // is only available once the argument was processed + // is only available once its result is stored $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor, $scope, $expr->getArgs()); if ($impurePoint !== null) { $impurePoints[] = new ImpurePoint($scopeBeforeArgs, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); @@ -310,6 +328,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($arrayWalkValueTypes !== null && $arrayWalkArrayArg !== null) { + $arrayWalkOriginalArrayType = $scope->getType($arrayWalkArrayArg); + $arrayWalkOriginalArrayNativeType = $scope->getNativeType($arrayWalkArrayArg); $arrayWalkValueType = $arrayWalkValueTypes[0]; $arrayWalkValueNativeType = $arrayWalkValueTypes[1]; $newArrayType = $arrayWalkOriginalArrayType->mapValueType(static fn (Type $type): Type => $arrayWalkValueType); @@ -325,6 +345,124 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex )->getScope(); } + // The return type is derived from $resolvedParametersAcceptor - the acceptor + // processArgs() selected from the arg types gathered on the arg-to-arg + // evolving scope (type-driven, generics resolved). When null + // (native-types-promoted, on-demand / synthetic pricing, or special cases + // inside resolveReturnType), the acceptor is re-derived from the + // already-processed argument results on the asking scope. + $storageRef = WeakReference::create($storage); + $typeCallback = $isEarlyTerminating + ? static fn (bool $nativeTypesPromoted): Type => new NeverType(true) + : function (bool $nativeTypesPromoted) use ($nodeScopeResolver, $beforeScope, $expr, $nameResult, $resolvedParametersAcceptor, $argsResult, $storageRef): Type { + // for always-true/always-false type checks the call's own narrowing + // (already produced as this result's specifyTypesCallback) decides + // the return type - the verdict is a read of that narrowing, not a + // second derivation. The result is looked up through a weak storage + // reference: this callback is owned by that very result, and a + // strong backedge would be a cycle (PHPStan runs with gc_disable()). + if ( + !$nativeTypesPromoted + && $expr->name instanceof Name + && in_array($expr->name->toLowerString(), ['array_key_exists', 'key_exists', 'in_array', 'is_subclass_of'], true) + ) { + $callStorage = $storageRef->get(); + $callResult = $callStorage === null ? null : $callStorage->findExpressionResult($expr); + if ($callResult !== null) { + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($beforeScope, $expr, $callResult, $argsResult); + if ($isAlways !== null) { + return new ConstantBooleanType($isAlways); + } + } + } + + return $this->resolveReturnType( + $nodeScopeResolver, + $beforeScope, + $nativeTypesPromoted, + $expr, + $nameResult, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $argsResult, + ); + }; + $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $expr, + $normalizedExpr, + $nameResult, + $resolvedParametersAcceptor, + $specifyContext, + ); + + // A type constraint on a (narrowable, i.e. non-side-effecting, non-first-class) + // function call narrows the call itself - the inside-out equivalent of + // createForExpr's FuncCall purity gate + tail entry. An impure call narrows to + // nothing. + $createTypesCallback = function (Type $type, TypeSpecifierContext $createContext, bool $nativeTypesPromoted) use ($nodeScopeResolver, $expr, $nameResult, $beforeScope, $argsResult): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if (!$this->isFuncCallNarrowable($nodeScopeResolver, $s, $expr, $nameResult)) { + return new SpecifiedTypes([], []); + } + + $types = $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext); + + // array_key_first/array_key_last/array_find_key return null iff the + // array has no matching key - a null constraint on the call narrows + // the array argument (both directions for first/last, non-empty only + // for find_key: an empty result does not mean an empty array) + if ( + $expr->name instanceof Name + && !$expr->isFirstClassCallable() + && isset($expr->getArgs()[0]) + && $type->isNull()->yes() + ) { + $funcName = $expr->name->toLowerString(); + $bothDirections = in_array($funcName, ['array_key_first', 'array_key_last'], true); + if ($bothDirections || $funcName === 'array_find_key') { + $argExpr = $expr->getArgs()[0]->value; + // the argument was processed with the call; a rewritten call + // (call_user_func) keys its results by the normalized arg nodes, + // those fall back to the stored-result read + $argResult = $argsResult->getArgResult($argExpr); + $argType = $argResult !== null + ? $argResult->getTypeOnScope($s, $s->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($argExpr, $s); + if ($argType->isArray()->yes() && ($bothDirections || $createContext->falsey())) { + $types = $types->unionWith( + $this->defaultNarrowingHelper->createForSubject($argExpr, new NonEmptyArrayType(), $createContext->negate(), $s), + ); + } + } + } + + return $types; + }; + + // Store a preliminary result carrying the type/specify callbacks before the + // throw-point return type is computed: getFunctionThrowPoint() resolves the + // return type through the typeCallback, whose type-check verdict reads this + // very result's narrowing, and dynamic return type extensions may ask about + // the call too. Without a stored result those asks would re-process this + // FuncCall on demand and recurse back into getFunctionThrowPoint(). The + // callbacks are scope-independent, so the preliminary result answers those + // asks correctly; finalize() below completes it with the resolved scope and + // throw/impure points. + $preliminaryResult = $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, + ); + $nodeScopeResolver->storeExpressionResult($storage, $expr, $preliminaryResult); + if ($normalizedExpr->name instanceof Expr) { $nameType = $scope->getType($normalizedExpr->name); if ( @@ -347,14 +485,28 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($functionReflection !== null) { - // A conditional-return never (e.g. `($x is Foo ? never : string)`) only - // resolves to never once the actual argument types are folded in by the - // type-driven resolved acceptor. + // The call's return type, computed from the already-processed argument + // results (resolveReturnType reads them from the stored results, + // never re-running processArgs) - asking Scope::getType() for the + // FuncCall here would re-enter this handler on demand, as its result is + // not stored yet. + // Resolve it through the stored preliminary result so the memoized + // value seeds the final result below - the first later type read + // would otherwise run resolveReturnType() again. + $returnType = $preliminaryResult->getKeepVoidType(false); + // The early structural check above (line ~180) only sees the unresolved + // acceptor return type; a conditional-return never (e.g. + // `($x is Foo ? never : string)`) only resolves to never once the actual + // argument types are folded in by the type-driven resolved acceptor. Read + // it from that acceptor's return type, not resolveReturnType(), which + // folds in call_user_func()/dynamic-extension special cases that must not + // make the call itself always-terminating (e.g. + // `call_user_func(fn() => exit())`). if ($resolvedParametersAcceptor !== null) { $resolvedReturnType = $resolvedParametersAcceptor->getReturnType(); $isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit()); } - $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $returnType, $normalizedExpr, $scope, $context); if ($functionThrowPoint !== null) { $throwPoints[] = $functionThrowPoint; } @@ -438,8 +590,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $stmt, $arrayArg, new NativeTypeExpr( - $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs, $normalizedExpr), - $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs->doNotTreatPhpDocTypesAsCertain(), $normalizedExpr), + $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs, $normalizedExpr, $argsResult), + $this->getArrayFunctionAppendingType($functionReflection, $scopeBeforeArgs->doNotTreatPhpDocTypesAsCertain(), $normalizedExpr, $argsResult), ), $nodeCallback, )->getScope(); @@ -474,13 +626,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && count($normalizedExpr->getArgs()) >= 2 ) { $arrayArg = $normalizedExpr->getArgs()[0]->value; - $arrayArgType = $scope->getType($arrayArg); - $arrayArgNativeType = $scope->getNativeType($arrayArg); + $arrayArgResult = $argsResult->getArgResult($arrayArg); + $arrayArgType = $arrayArgResult !== null ? $arrayArgResult->getType() : $scope->getType($arrayArg); + $arrayArgNativeType = $arrayArgResult !== null ? $arrayArgResult->getNativeType() : $scope->getNativeType($arrayArg); - $offsetType = $scopeBeforeArgs->getType($normalizedExpr->getArgs()[1]->value); + $offsetArg = $normalizedExpr->getArgs()[1]->value; + $offsetArgResult = $argsResult->getArgResult($offsetArg); + $offsetType = $offsetArgResult !== null ? $offsetArgResult->getType() : $scopeBeforeArgs->getType($offsetArg); if (isset($normalizedExpr->getArgs()[2])) { - $lengthType = $scopeBeforeArgs->getType($normalizedExpr->getArgs()[2]->value); + $lengthArg = $normalizedExpr->getArgs()[2]->value; + $lengthArgResult = $argsResult->getArgResult($lengthArg); + $lengthType = $lengthArgResult !== null ? $lengthArgResult->getType() : $scopeBeforeArgs->getType($lengthArg); } else { $lengthType = new NullType(); } @@ -620,20 +777,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->invalidateVolatileExpressions(); } - return $this->expressionResultFactory->create( - $scope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: $hasYield, - isAlwaysTerminating: $isAlwaysTerminating, - throwPoints: $throwPoints, - impurePoints: $impurePoints, - ); + return $preliminaryResult->finalize($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } private function getFunctionThrowPoint( FunctionReflection $functionReflection, ?ParametersAcceptor $parametersAcceptor, + Type $returnType, FuncCall $normalizedFuncCall, MutatingScope $scope, ExpressionContext $context, @@ -654,7 +804,6 @@ private function getFunctionThrowPoint( $throwType = $functionReflection->getThrowType(); if ($throwType === null) { - $returnType = $scope->getType($normalizedFuncCall); if ($returnType instanceof NeverType && $returnType->isExplicit()) { $throwType = new ObjectType(Throwable::class); } @@ -682,8 +831,7 @@ private function getFunctionThrowPoint( || $requiredParameters > 0 || count($normalizedFuncCall->getArgs()) > 0 ) { - $functionReturnedType = $scope->getType($normalizedFuncCall); - if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($functionReturnedType)->yes()) { + if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($returnType)->yes()) { return InternalThrowPoint::createImplicit($scope, $normalizedFuncCall); } } @@ -692,19 +840,23 @@ private function getFunctionThrowPoint( return null; } - private function getArrayFunctionAppendingType(FunctionReflection $functionReflection, Scope $scope, FuncCall $expr): Type + private function getArrayFunctionAppendingType(FunctionReflection $functionReflection, Scope $scope, FuncCall $expr, ArgsResult $argsResult): Type { $arrayArg = $expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); + $arrayArgResult = $argsResult->getArgResult($arrayArg); + // closure args have no ExpressionResult (ProcessClosureResult carries none); + // they fall back to the scope, every other arg reads its captured result. + $arrayType = $arrayArgResult !== null ? $arrayArgResult->getTypeOnScope($scope->toMutatingScope(), $scope->toMutatingScope()->nativeTypesPromoted) : $scope->getType($arrayArg); $callArgs = array_slice($expr->getArgs(), 1); /** * @param Arg[] $callArgs * @param callable(?Type, Type, bool): void $setOffsetValueType */ - $setOffsetValueTypes = static function (Scope $scope, array $callArgs, callable $setOffsetValueType, ?bool &$nonConstantArrayWasUnpacked = null): void { + $setOffsetValueTypes = static function (Scope $scope, array $callArgs, callable $setOffsetValueType, ?bool &$nonConstantArrayWasUnpacked = null) use ($argsResult): void { foreach ($callArgs as $callArg) { - $callArgType = $scope->getType($callArg->value); + $callArgResult = $argsResult->getArgResult($callArg->value); + $callArgType = $callArgResult !== null ? $callArgResult->getTypeOnScope($scope->toMutatingScope(), $scope->toMutatingScope()->nativeTypesPromoted) : $scope->getType($callArg->value); if ($callArg->unpack) { $constantArrays = $callArgType->getConstantArrays(); if (count($constantArrays) === 1) { @@ -835,27 +987,56 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra return $arrayType; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * The call-expression type is derived from $preResolvedAcceptor - the acceptor + * processArgs() selected from the arg types gathered on the arg-to-arg evolving + * scope (type-driven, generics resolved). When null (native-types-promoted, or + * a callable callee whose name was processed elsewhere), it falls back to a + * structural acceptor combined from the variants - generic resolution from the + * actual arg types lives in $preResolvedAcceptor, recomputed by on-demand / + * synthetic pricing that re-runs processArgs(). + * + * @param FuncCall $expr + */ + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, Expr $expr, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ArgsResult $argsResult): Type { - if ( - $expr->name instanceof Name - && $this->earlyTerminatingCallHelper->isEarlyTerminatingFunctionCall($expr->name->toString()) - ) { - return new NeverType(true); - } + // the operands/arguments were processed during processExpr; read their + // already computed results instead of re-walking via Scope::getType(). + // The function reflection and dynamic-return-type extensions run on the + // reflection scope (the lexical context / beforeScope). Synthetic nodes the + // resolver builds (e.g. Clone_, call_user_func's inner FuncCall) are priced + // on demand by the same helper. + $getType = static function (Expr $e) use ($expr, $nameResult, $reflectionScope, $nodeScopeResolver, $argsResult, $nativeTypesPromoted): Type { + if ($nameResult !== null && $e === $expr->name) { + return $nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType(); + } + + $argResult = $argsResult->getArgResult($e); + if ($argResult !== null) { + return $nativeTypesPromoted ? $argResult->getNativeType() : $argResult->getType(); + } + + // Synthetic nodes (call_user_func's inner FuncCall, clone-with's Clone_) + // have no captured arg result; they are priced on demand. + $s = $nativeTypesPromoted ? $reflectionScope->doNotTreatPhpDocTypesAsCertain() : $reflectionScope; + + return $nodeScopeResolver->processSyntheticOnDemand($e, $s)->getTypeOnScope($s, $s->nativeTypesPromoted); + }; if ($expr->name instanceof Expr) { - $calledOnType = $scope->getType($expr->name); + $calledOnType = $getType($expr->name); if ($calledOnType->isCallable()->no()) { return new ErrorType(); } - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $calledOnType->getCallableParametersAcceptors($scope), - null, - ); + if ($preResolvedAcceptor !== null) { + $parametersAcceptor = $preResolvedAcceptor; + } else { + $variants = $calledOnType->getCallableParametersAcceptors($reflectionScope); + $parametersAcceptor = count($variants) === 1 + ? $variants[0] + : ParametersAcceptorSelector::combineAcceptors($variants); + } $functionName = null; if ($expr->name instanceof String_) { @@ -871,9 +1052,9 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type } $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); - if ($normalizedNode !== null && $functionName !== null && $this->reflectionProvider->hasFunction($functionName, $scope)) { - $functionReflection = $this->reflectionProvider->getFunction($functionName, $scope); - $resolvedType = $this->getDynamicFunctionReturnType($scope, $normalizedNode, $functionReflection); + if ($normalizedNode !== null && $functionName !== null && $this->reflectionProvider->hasFunction($functionName, $reflectionScope)) { + $functionReflection = $this->reflectionProvider->getFunction($functionName, $reflectionScope); + $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection, $argsResult); if ($resolvedType !== null) { return $resolvedType; } @@ -882,45 +1063,47 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return $parametersAcceptor->getReturnType(); } - if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + if (!$this->reflectionProvider->hasFunction($expr->name, $reflectionScope)) { return new ErrorType(); } - $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - if ($scope->nativeTypesPromoted) { + $functionReflection = $this->reflectionProvider->getFunction($expr->name, $reflectionScope); + if ($nativeTypesPromoted) { return ParametersAcceptorSelector::combineAcceptors($functionReflection->getVariants())->getNativeReturnType(); } if ($functionReflection->getName() === 'call_user_func') { - $result = ArgumentsNormalizer::reorderCallUserFuncArguments($expr, $scope); + $result = ArgumentsNormalizer::reorderCallUserFuncArguments($expr, $reflectionScope); if ($result !== null) { [, $innerFuncCall] = $result; - return $scope->getType($innerFuncCall); + return $getType($innerFuncCall); } } if ($functionReflection->getName() === 'call_user_func_array') { - $result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $scope); + $result = ArgumentsNormalizer::reorderCallUserFuncArrayArguments($expr, $reflectionScope); if ($result !== null) { [, $innerFuncCall] = $result; - return $scope->getType($innerFuncCall); + return $getType($innerFuncCall); } } - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $functionReflection->getVariants(), - $functionReflection->getNamedArgumentsVariants(), - ); + if ($preResolvedAcceptor !== null) { + $parametersAcceptor = $preResolvedAcceptor; + } else { + $variants = $functionReflection->getVariants(); + $parametersAcceptor = count($variants) === 1 + ? $variants[0] + : ParametersAcceptorSelector::combineAcceptors($variants); + } $normalizedNode = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr); if ($normalizedNode !== null) { if ($functionReflection->getName() === 'clone' && count($normalizedNode->getArgs()) > 0) { - $cloneType = $scope->getType(new Expr\Clone_($normalizedNode->getArgs()[0]->value)); + $cloneType = $getType(new Expr\Clone_($normalizedNode->getArgs()[0]->value)); if (count($normalizedNode->getArgs()) === 2) { - $propertiesType = $scope->getType($normalizedNode->getArgs()[1]->value); + $propertiesType = $getType($normalizedNode->getArgs()[1]->value); if ($propertiesType->isConstantArray()->yes()) { $constantArrays = $propertiesType->getConstantArrays(); if (count($constantArrays) === 1) { @@ -941,31 +1124,37 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return $cloneType; } - $resolvedType = $this->getDynamicFunctionReturnType($scope, $normalizedNode, $functionReflection); + $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection, $argsResult); if ($resolvedType !== null) { return $resolvedType; } } - return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); + // the typeCallback keeps void; ExpressionResult projects void->null for + // value reads, getKeepVoidType() keeps it + return $parametersAcceptor->getReturnType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the + * FunctionTypeSpecifyingExtensions, conditional-return-type and @phpstan-assert + * narrowing are invoked on the already-processed argument results. The acceptor + * is $resolvedParametersAcceptor (type-driven, generics resolved by processArgs) + * rather than re-selected from the args on the asking scope. The subject's own + * default narrowing comes from DefaultNarrowingHelper instead of + * TypeSpecifier::handleDefaultTruthyOrFalseyContext(), which would re-enter this + * expression through TypeSpecifier::create(). + * + * @param FuncCall $expr + */ + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, FuncCall $normalizedExpr, ?ExpressionResult $nameResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes { if ($expr->name instanceof Name) { if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { - // lazy create parametersAcceptor, as creation can be expensive - $parametersAcceptor = null; - $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - $normalizedExpr = $expr; $args = $expr->getArgs(); - if (count($args) > 0) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); - $normalizedExpr = ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $expr) ?? $expr; - } - foreach ($typeSpecifier->getFunctionTypeSpecifyingExtensions() as $extension) { + foreach ($this->typeSpecifier->getFunctionTypeSpecifyingExtensions() as $extension) { if (!$extension->isFunctionSupported($functionReflection, $normalizedExpr, $context)) { continue; } @@ -973,56 +1162,64 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e return $extension->specifyTypes($functionReflection, $normalizedExpr, $scope, $context); } - if (count($args) > 0) { - $specifiedTypes = $typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if (count($args) > 0 && $resolvedParametersAcceptor !== null) { + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } } $assertions = $functionReflection->getAsserts(); - if ($assertions->getAll() !== []) { - $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $args, $functionReflection->getVariants(), $functionReflection->getNamedArgumentsVariants()); - + if ($assertions->getAll() !== [] && $resolvedParametersAcceptor !== null) { $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( $type, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $resolvedParametersAcceptor->getResolvedTemplateTypeMap(), + $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) ->setRootExpr($specifiedTypes->getRootExpr()); } } } - return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $this->defaultFuncCallNarrowing($nodeScopeResolver, $scope, $expr, $nameResult, $context); } - $specifiedTypes = $this->specifyTypesFromCallableCall($typeSpecifier, $context, $expr, $scope); + $specifiedTypes = $this->specifyTypesFromCallableCall($nodeScopeResolver, $context, $expr, $nameResult, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } - return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $this->defaultFuncCallNarrowing($nodeScopeResolver, $scope, $expr, $nameResult, $context); } - private function specifyTypesFromCallableCall(TypeSpecifier $typeSpecifier, TypeSpecifierContext $context, FuncCall $call, Scope $scope): ?SpecifiedTypes + private function specifyTypesFromCallableCall(NodeScopeResolver $nodeScopeResolver, TypeSpecifierContext $context, FuncCall $call, ?ExpressionResult $nameResult, ?ParametersAcceptor $resolvedParametersAcceptor, MutatingScope $scope): ?SpecifiedTypes { if (!$call->name instanceof Expr) { return null; } - $calleeType = $scope->getType($call->name); + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + + $calleeType = $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); $assertions = null; $parametersAcceptor = null; if ($calleeType->isCallable()->yes()) { - $variants = $calleeType->getCallableParametersAcceptors($scope); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $call->getArgs(), $variants); + if ($resolvedParametersAcceptor !== null) { + $parametersAcceptor = $resolvedParametersAcceptor; + } else { + $variants = $calleeType->getCallableParametersAcceptors($scope); + $parametersAcceptor = count($variants) === 1 + ? $variants[0] + : ParametersAcceptorSelector::combineAcceptors($variants); + } if ($parametersAcceptor instanceof CallableParametersAcceptor) { $assertions = $parametersAcceptor->getAsserts(); } @@ -1039,34 +1236,92 @@ private function specifyTypesFromCallableCall(TypeSpecifier $typeSpecifier, Type TemplateTypeVariance::createInvariant(), )); - return $typeSpecifier->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); + return $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); } - private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $normalizedNode, FunctionReflection $functionReflection): ?Type + /** + * The default truthy/falsey narrowing of the call expression itself, gated by + * the same purity check TypeSpecifier::create() applies: a function with side + * effects (or an unknown / impure callee whose result is not remembered) is not + * narrowable - calling it twice may yield different values - so it contributes + * no entry. Mirrors create()'s FuncCall handling inside-out, without re-entering + * this expression through create(). + * + */ + private function defaultFuncCallNarrowing(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, FuncCall $expr, ?ExpressionResult $nameResult, TypeSpecifierContext $context): SpecifiedTypes { - foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicFunctionReturnTypeExtensions($functionReflection) as $dynamicFunctionReturnTypeExtension) { - $resolvedType = $dynamicFunctionReturnTypeExtension->getTypeFromFunctionCall( - $functionReflection, - $normalizedNode, - $scope, - ); + if (!$this->isFuncCallNarrowable($nodeScopeResolver, $scope, $expr, $nameResult)) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } - if ($resolvedType !== null) { - return $resolvedType; + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + private function isFuncCallNarrowable(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, FuncCall $expr, ?ExpressionResult $nameResult): bool + { + if ($expr->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($expr->name, $scope)) { + // backwards compatibility with previous behaviour + return false; } + + $hasSideEffects = $this->reflectionProvider->getFunction($expr->name, $scope)->hasSideEffects(); + if ($hasSideEffects->yes()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); } - // for always-true/always-false type checks the call's own narrowing - // decides the return type - the verdict reads the same specified types - // the check contributes when used as a condition - if ( - $normalizedNode->name instanceof Name - && in_array($normalizedNode->name->toLowerString(), ['array_key_exists', 'key_exists', 'in_array', 'is_subclass_of'], true) - ) { - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $normalizedNode); - if ($isAlways !== null) { - return new ConstantBooleanType($isAlways); + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + + $nameType = $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + if (!$nameType->isCallable()->yes()) { + return true; + } + + $isPure = null; + foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { + $variantIsPure = $variant->isPure(); + $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); + } + + if ($isPure === null) { + return true; + } + + if ($isPure->no()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $isPure->yes(); + } + + private function getDynamicFunctionReturnType(MutatingScope $scope, FuncCall $normalizedNode, FunctionReflection $functionReflection, ArgsResult $argsResult): ?Type + { + $extensions = $this->dynamicReturnTypeExtensionRegistry->getDynamicFunctionReturnTypeExtensions($functionReflection); + + // re-expose the already-processed arguments so an extension's + // Scope::getType($arg->value) reads the stored result instead of re-walking + // the argument on demand (the call's argument storage frame is no longer + // current when the return type is asked lazily) + $popPrimedStorage = $this->storagePrimer->pushPrimedStorage($scope, $normalizedNode->getArgs(), $argsResult); + try { + foreach ($extensions as $dynamicFunctionReturnTypeExtension) { + $resolvedType = $dynamicFunctionReturnTypeExtension->getTypeFromFunctionCall( + $functionReflection, + $normalizedNode, + $scope, + ); + + if ($resolvedType !== null) { + return $resolvedType; + } } + } finally { + $popPrimedStorage(); } return null; diff --git a/src/Analyser/ExprHandler/Helper/DynamicReturnTypeStoragePrimer.php b/src/Analyser/ExprHandler/Helper/DynamicReturnTypeStoragePrimer.php new file mode 100644 index 00000000000..278b0152984 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DynamicReturnTypeStoragePrimer.php @@ -0,0 +1,70 @@ +value) reads the stored result instead of re-walking the + * argument on demand. The call's own argument storage frame is no longer current + * when the return type is asked lazily (from a rule, a parent expression, a later + * statement), so those arguments would otherwise miss and be re-priced. + */ +#[AutowiredService] +final class DynamicReturnTypeStoragePrimer +{ + + /** + * Push a transient storage carrying the argument results (current storage as + * fallback, so every non-argument getType is unchanged) and return the matching + * pop - always call it, in a finally. Closures/arrow functions are excluded: + * the bridge computes their type directly on the asking scope (getClosureType), + * which a processArgs-time stored result would shadow with a stale type. + * + * @param Arg[] $args + * @return Closure(): void + */ + public function pushPrimedStorage(MutatingScope $scope, array $args, ?ArgsResult $argsResult): Closure + { + $noop = static function (): void { + }; + if ($argsResult === null) { + return $noop; + } + + $current = $scope->getCurrentExpressionResultStorage(); + $primed = $current !== null ? $current->duplicate() : new ExpressionResultStorage(); + $primedAny = false; + foreach ($args as $arg) { + if ($arg->value instanceof ClosureExpr || $arg->value instanceof ArrowFunction) { + continue; + } + $argResult = $argsResult->getArgResult($arg->value); + if ($argResult === null) { + continue; + } + $primed->storeExpressionResult($arg->value, $argResult); + $primedAny = true; + } + + if (!$primedAny) { + return $noop; + } + + $scope->pushExpressionResultStorage($primed); + + return static function () use ($scope): void { + $scope->popExpressionResultStorage(); + }; + } + +} diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 58ed6c39ac6..671824c3ed6 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -9,8 +9,12 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\SpecifiedTypes; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; +use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\ErrorType; +use PHPStan\Type\MixedType; use function sprintf; #[AutowiredService] @@ -20,17 +24,23 @@ final class ImplicitToStringCallHelper public function __construct( private PhpVersion $phpVersion, private MethodThrowPointHelper $methodThrowPointHelper, + private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private ExpressionResultFactory $expressionResultFactory, ) { } - public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): ExpressionResult + /** + * @param ExpressionResult $exprResult the already-computed result of $expr - + * every caller processed it on $scope, so this helper reads its type + * directly instead of re-walking via Scope::getType() + */ + public function processImplicitToStringCall(Expr $expr, MutatingScope $scope, ExpressionResult $exprResult): ExpressionResult { $throwPoints = []; $impurePoints = []; - $exprType = $scope->getType($expr); + $exprType = $exprResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); $toStringMethod = null; if (!$exprType->isObject()->no()) { @@ -45,6 +55,8 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } @@ -59,12 +71,22 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E } if ($this->phpVersion->throwsOnStringCast()) { + // the __toString() call's return type resolves directly (the receiver + // type is already in hand); the fabricated node is only the payload + // dynamic extensions receive - nothing walks it + $toStringCall = new Expr\MethodCall($expr, new Identifier('__toString')); + if ($scope->nativeTypesPromoted) { + $toStringReturnType = ParametersAcceptorSelector::combineAcceptors($toStringMethod->getVariants())->getNativeReturnType(); + } else { + $toStringReturnType = $this->methodCallReturnTypeHelper->methodCallReturnType($scope, $exprType, '__toString', $toStringCall) ?? new ErrorType(); + } $throwPoint = $this->methodThrowPointHelper->getThrowPoint( $toStringMethod, $toStringMethod->getOnlyVariant(), - new Expr\MethodCall($expr, new Identifier('__toString')), + $toStringCall, $scope, ExpressionContext::createDeep(), + $toStringReturnType, ); if ($throwPoint !== null) { $throwPoints[] = $throwPoint; @@ -79,6 +101,8 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E isAlwaysTerminating: false, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index df89e968336..f990ecbb07c 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -4,9 +4,11 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\MethodCall; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\DynamicReturnTypeExtensionRegistry; use PHPStan\Type\ObjectType; @@ -20,6 +22,7 @@ final class MethodCallReturnTypeHelper public function __construct( private DynamicReturnTypeExtensionRegistry $dynamicReturnTypeExtensionRegistry, + private DynamicReturnTypeStoragePrimer $storagePrimer, ) { } @@ -29,6 +32,8 @@ public function methodCallReturnType( Type $typeWithMethod, string $methodName, MethodCall|Expr\StaticCall $methodCall, + ?ParametersAcceptor $preResolvedAcceptor = null, + ?ArgsResult $argsResult = null, ): ?Type { $typeWithMethod = $scope->filterTypeWithMethod($typeWithMethod, $methodName); @@ -37,7 +42,7 @@ public function methodCallReturnType( } $methodReflection = $typeWithMethod->getMethod($methodName, $scope); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( $scope, $methodCall->getArgs(), $methodReflection->getVariants(), @@ -49,70 +54,79 @@ public function methodCallReturnType( $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); } if ($normalizedMethodCall === null) { - return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $methodCall); + return $parametersAcceptor->getReturnType(); } - $resolvedTypes = []; - $allClassNames = $typeWithMethod->getObjectClassNames(); - $handledClassNames = []; - foreach ($allClassNames as $className) { - if ($normalizedMethodCall instanceof MethodCall) { - foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { - if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { - continue; - } + // re-expose the already-processed arguments so an extension's + // Scope::getType($arg->value) reads the stored result instead of re-walking + // the argument on demand (the call's argument storage frame is no longer + // current when the return type is asked lazily) + $popPrimedStorage = $this->storagePrimer->pushPrimedStorage($scope, $normalizedMethodCall->getArgs(), $argsResult); + try { + $resolvedTypes = []; + $allClassNames = $typeWithMethod->getObjectClassNames(); + $handledClassNames = []; + foreach ($allClassNames as $className) { + if ($normalizedMethodCall instanceof MethodCall) { + foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicMethodReturnTypeExtensionsForClass($className) as $dynamicMethodReturnTypeExtension) { + if (!$dynamicMethodReturnTypeExtension->isMethodSupported($methodReflection)) { + continue; + } - $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($methodReflection, $normalizedMethodCall, $scope); - if ($resolvedType === null) { - continue; - } + $resolvedType = $dynamicMethodReturnTypeExtension->getTypeFromMethodCall($methodReflection, $normalizedMethodCall, $scope); + if ($resolvedType === null) { + continue; + } - $resolvedTypes[] = $resolvedType; - $handledClassNames[] = $className; - } - } else { - foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { - if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($methodReflection)) { - continue; + $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; } + } else { + foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicStaticMethodReturnTypeExtensionsForClass($className) as $dynamicStaticMethodReturnTypeExtension) { + if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($methodReflection)) { + continue; + } - $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( - $methodReflection, - $normalizedMethodCall, - $scope, - ); - if ($resolvedType === null) { - continue; - } + $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( + $methodReflection, + $normalizedMethodCall, + $scope, + ); + if ($resolvedType === null) { + continue; + } - $resolvedTypes[] = $resolvedType; - $handledClassNames[] = $className; + $resolvedTypes[] = $resolvedType; + $handledClassNames[] = $className; + } } } - } - if (count($resolvedTypes) > 0) { - if (count($allClassNames) !== count($handledClassNames)) { - $remainingType = $typeWithMethod; - foreach ($handledClassNames as $handledClassName) { - $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); - } - if ($remainingType->hasMethod($methodName)->yes()) { - $remainingMethod = $remainingType->getMethod($methodName, $scope); - $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $methodCall->getArgs(), - $remainingMethod->getVariants(), - $remainingMethod->getNamedArgumentsVariants(), - ); - $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + if (count($resolvedTypes) > 0) { + if (count($allClassNames) !== count($handledClassNames)) { + $remainingType = $typeWithMethod; + foreach ($handledClassNames as $handledClassName) { + $remainingType = TypeCombinator::remove($remainingType, new ObjectType($handledClassName)); + } + if ($remainingType->hasMethod($methodName)->yes()) { + $remainingMethod = $remainingType->getMethod($methodName, $scope); + $remainingParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $methodCall->getArgs(), + $remainingMethod->getVariants(), + $remainingMethod->getNamedArgumentsVariants(), + ); + $resolvedTypes[] = $remainingParametersAcceptor->getReturnType(); + } } - } - return VoidToNullTypeTransformer::transform(TypeCombinator::union(...$resolvedTypes), $methodCall); + return TypeCombinator::union(...$resolvedTypes); + } + } finally { + $popPrimedStorage(); } - return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $methodCall); + return $parametersAcceptor->getReturnType(); } } diff --git a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php index 5edef897727..0c1da968057 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -45,12 +45,19 @@ public function __construct( { } + /** + * @param Type $methodCallReturnType the resolved return type of $normalizedMethodCall; + * passed in by the caller so this helper never asks Scope::getType() itself + * (the old-world call handlers resolve it directly, the new-world toString + * path prices the synthetic call on demand) + */ public function getThrowPoint( MethodReflection $methodReflection, ParametersAcceptor $parametersAcceptor, MethodCall|StaticCall $normalizedMethodCall, MutatingScope $scope, ExpressionContext $context, + Type $methodCallReturnType, ): ?InternalThrowPoint { if ($normalizedMethodCall instanceof MethodCall) { @@ -91,8 +98,7 @@ public function getThrowPoint( $throwType = $methodReflection->getThrowType(); if ($throwType === null) { - $returnType = $scope->getType($normalizedMethodCall); - if ($returnType instanceof NeverType && $returnType->isExplicit()) { + if ($methodCallReturnType instanceof NeverType && $methodCallReturnType->isExplicit()) { $throwType = new ObjectType(Throwable::class); } } @@ -102,8 +108,7 @@ public function getThrowPoint( return InternalThrowPoint::createExplicit($scope, $throwType, $normalizedMethodCall, true); } } elseif ($this->implicitThrows) { - $methodReturnedType = $scope->getType($normalizedMethodCall); - if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($methodReturnedType)->yes()) { + if (!$context->isInThrow() || !(new ObjectType(Throwable::class))->isSuperTypeOf($methodCallReturnType)->yes()) { return InternalThrowPoint::createImplicit($scope, $normalizedMethodCall); } } @@ -130,7 +135,8 @@ public function getThrowPointsForCallOnType(MutatingScope $scope, ExpressionCont return [InternalThrowPoint::createImplicit($scope, $methodCall)]; } - $throwPoint = $this->getThrowPoint($methodReflection, ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants()), $methodCall, $scope, $context); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($methodCall->getArgs(), $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); + $throwPoint = $this->getThrowPoint($methodReflection, $parametersAcceptor, $methodCall, $scope, $context, $parametersAcceptor->getReturnType()); if ($throwPoint === null) { return []; } diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 8c2a5754ec3..22a39f88850 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -3,26 +3,25 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Identifier; -use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; +use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; @@ -32,14 +31,17 @@ use PHPStan\Node\InvalidateExprNode; use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\ExtendedParametersAcceptor; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\Generic\TemplateTypeVarianceMap; use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; +use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; @@ -57,13 +59,15 @@ final class MethodCallHandler implements ExprHandler { public function __construct( - private EarlyTerminatingCallHelper $earlyTerminatingCallHelper, private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private MethodThrowPointHelper $methodThrowPointHelper, private ReflectionProvider $reflectionProvider, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { } @@ -83,9 +87,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && strtolower($expr->name->name) === 'call' && isset($expr->getArgs()[0]) ) { + // process the new-$this argument as a read so enterClosureCall() consumes + // its stored ExpressionResult instead of reading the unprocessed node via + // Scope::getType(). processArgs() below processes it again as call()'s first + // argument; the NoopNodeCallback here avoids a duplicate node-callback. + $newThisResult = $nodeScopeResolver->processExprNode($stmt, $expr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $closureCallScope = $scope->enterClosureCall( - $scope->getType($expr->getArgs()[0]->value), - $scope->getNativeType($expr->getArgs()[0]->value), + $newThisResult->getType(), + $newThisResult->getNativeType(), ); } @@ -102,7 +111,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $variants = []; $namedArgumentsVariants = null; $methodReflection = null; + $nameResult = null; + // the var was processed above as the receiver; read its already-computed + // result instead of re-walking via Scope::getType(). $calledOnType = $varResult->getType(); + // A call configured as early-terminating never returns: give it an explicit + // never so the statement's exit point follows from the result type, instead of + // NodeScopeResolver re-deriving it via Scope::getType(). + $isEarlyTerminating = $expr->name instanceof Identifier + && $this->earlyTerminatingHelper->isEarlyTerminatingMethodCall($expr->name->name, $calledOnType); + $isAlwaysTerminating = $isAlwaysTerminating || $isEarlyTerminating; if ($expr->name instanceof Identifier) { $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); @@ -115,9 +133,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); } } else { - $methodNameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); - $throwPoints = array_merge($throwPoints, $methodNameResult->getThrowPoints()); - $scope = $methodNameResult->getScope(); + $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); + $throwPoints = array_merge($throwPoints, $nameResult->getThrowPoints()); + $scope = $nameResult->getScope(); } if ($methodReflection !== null) { @@ -156,6 +174,90 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); + $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); + + // The return type is derived from $resolvedParametersAcceptor - the acceptor + // processArgs() selected from the arg types gathered on the arg-to-arg + // evolving scope (type-driven, generics resolved). When null + // (native-types-promoted, or on-demand / synthetic pricing) the acceptor is + // re-derived from the already-processed argument results on the asking scope. + $typeCallback = $isEarlyTerminating + ? static fn (bool $nativeTypesPromoted): Type => new NeverType(true) + : fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( + $beforeScope, + $nativeTypesPromoted, + $expr, + $varResult, + $nameResult, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $argsResult, + ); + $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $expr, + $normalizedExpr, + $varResult, + $resolvedParametersAcceptor, + $specifyContext, + ); + + // A type constraint on a (narrowable, i.e. non-side-effecting) method call + // narrows the call itself - the inside-out equivalent of createForExpr's + // MethodCall purity gate + tail entry. An impure call narrows to nothing. + $createTypesCallback = function (Type $type, TypeSpecifierContext $createContext, bool $nativeTypesPromoted) use ($expr, $varResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if (!$this->isMethodCallNarrowable($s, $expr, $varResult)) { + // the call's value is not remembered, but a nullsafe receiver + // chain still narrows not-null + $resultStorage = $s->getCurrentExpressionResultStorage(); + + return $this->defaultNarrowingHelper->createNullsafeReceiverOnlyTypes( + $s, + $expr, + $resultStorage !== null ? $resultStorage->findExpressionResult($expr) : null, + $type, + $createContext, + ); + } + + // delegate with this call's own stored result (looked up at ask time, + // never captured) so a nullsafe receiver chain fans "not null" through + // the containsNullsafe state - the FromResultState variant skips the + // createTypesCallback consult that would re-enter this closure + $resultStorage = $s->getCurrentExpressionResultStorage(); + + return $this->defaultNarrowingHelper->createSubjectTypesFromResultState( + $s, + $expr, + $resultStorage !== null ? $resultStorage->findExpressionResult($expr) : null, + $type, + $createContext, + ); + }; + + // Store a preliminary result carrying the type/specify callbacks before the + // throw point is computed: the method throw point resolves the return type + // (resolveReturnType below) through dynamic return type extensions, which can + // narrow this very call on demand. Without a stored result that narrowing + // would re-process this MethodCall on demand and recurse. The callbacks are + // scope-independent, so the preliminary result answers those asks correctly; + // finalize() below completes it with the resolved scope and + // throw/impure points. + $preliminaryResult = $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + containsNullsafe: $varResult->containsNullsafe(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, + ); + $nodeScopeResolver->storeExpressionResult($storage, $expr, $preliminaryResult); if ($methodReflection !== null) { // The early structural check above only sees the unresolved acceptor @@ -166,7 +268,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $resolvedReturnType = $resolvedParametersAcceptor->getReturnType(); $isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit()); } - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + + // The call's return type, computed from the already-processed argument + // results (resolveReturnType reads them via the receiver/name results, + // never re-running processArgs) - asking + // Scope::getType() for the MethodCall here would re-enter this handler on + // demand, as its final result is not stored yet. + // Resolve it through the stored preliminary result so the memoized + // value seeds the final result below - the first later type read + // would otherwise run resolveReturnType() again. + $methodCallReturnType = $preliminaryResult->getKeepVoidType(false); + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $methodCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -198,7 +310,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $acceptorForGenerics instanceof ExtendedParametersAcceptor ? $acceptorForGenerics->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createCovariant(), ), - $scope->getNativeType($normalizedExpr->var), + $varResult->getNativeType(), ); } } @@ -220,18 +332,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - $result = $this->expressionResultFactory->create( - $scope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: $hasYield, - isAlwaysTerminating: $isAlwaysTerminating, - throwPoints: $throwPoints, - impurePoints: $impurePoints, - containsNullsafe: $varResult->containsNullsafe(), - ); + $result = $preliminaryResult->finalize($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); - $calledOnType = $originalScope->getType($expr->var); + // the var was processed above as the receiver; read its already-computed + // result on the original scope instead of re-walking via Scope::getType(). + $calledOnType = $varResult->getType(); if (!$expr->name instanceof Identifier) { return $result; } @@ -257,6 +362,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), + containsNullsafe: $varResult->containsNullsafe(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } } @@ -264,72 +373,106 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return $result; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * The call-expression type is derived from $preResolvedAcceptor - the acceptor + * processArgs() selected from the arg types gathered on the arg-to-arg evolving + * scope (type-driven, generics resolved). When null (native-types-promoted, or + * on-demand / synthetic pricing) it falls back to re-selecting from the args via + * MethodCallReturnTypeHelper on the asking scope. + * + * The receiver/name were processed during processExpr; their already computed + * results are read instead of re-walking via Scope::getType(). The dynamic-name + * branch builds a synthetic MethodCall priced on demand by the resolver. + * + */ + private function resolveReturnType(MutatingScope $reflectionScope, bool $nativeTypesPromoted, MethodCall $expr, ExpressionResult $varResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ?ArgsResult $argsResult): Type { - if ( - $expr->name instanceof Identifier - && $this->earlyTerminatingCallHelper->isEarlyTerminatingMethodCall($expr->name->name, $scope->getType($expr->var)) - ) { - return new NeverType(true); - } - - if ($expr->name instanceof Identifier) { - if ($scope->nativeTypesPromoted) { - $methodReflection = $scope->getMethodReflection( - $scope->getNativeType($expr->var), - $expr->name->name, - ); + // the receiver (scope-dependent) is read from the operand result; the + // method reflection and dynamic-return-type extensions run on the + // reflection scope (the lexical context / beforeScope). + $calledOnType = $nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType(); + // a call on a nullsafe chain whose receiver is currently nullable + // short-circuits to null - the receiver result carries whether the chain + // contains a ?-> (a plain nullable receiver does not propagate). + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($calledOnType) + ? TypeCombinator::addNull($type) + : $type; + + $resolveMethod = function (string $methodName, MethodCall $methodCall) use ($reflectionScope, $nativeTypesPromoted, $calledOnType, $preResolvedAcceptor, $argsResult): Type { + if ($nativeTypesPromoted) { + $methodReflection = $reflectionScope->getMethodReflection($calledOnType, $methodName); if ($methodReflection === null) { - $returnType = new ErrorType(); - } else { - $returnType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); + return new ErrorType(); } - return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $returnType); + return ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); } - $returnType = $this->methodCallReturnTypeHelper->methodCallReturnType( - $scope, - $scope->getType($expr->var), - $expr->name->name, - $expr, - ); - if ($returnType === null) { - $returnType = new ErrorType(); - } - return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $returnType); + return $this->methodCallReturnTypeHelper->methodCallReturnType( + $reflectionScope, + $calledOnType, + $methodName, + $methodCall, + $preResolvedAcceptor, + $argsResult, + ) ?? new ErrorType(); + }; + + if ($expr->name instanceof Identifier) { + return $shortCircuit($resolveMethod($expr->name->name, $expr)); } - $nameType = $scope->getType($expr->name); + // dynamic method call $obj->$name(): resolve each possible name on the + // reflection scope. The asking scope is not narrowed per name, so such + // calls can be less precise. Every caller walks a non-Identifier name + // and passes its result. + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + $nameType = $nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType(); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $scope - ->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))) - ->getType(new MethodCall($expr->var, new Identifier($constantString->getValue()), $expr->args)), $nameType->getConstantStrings()), + ...array_map(static function ($constantString) use ($expr, $resolveMethod): Type { + if ($constantString->getValue() === '') { + return new ErrorType(); + } + + return $resolveMethod( + $constantString->getValue(), + new MethodCall($expr->var, new Identifier($constantString->getValue()), $expr->args), + ); + }, $nameType->getConstantStrings()), ); } return new MixedType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the + * MethodTypeSpecifyingExtensions, conditional-return-type and @phpstan-assert + * narrowing are invoked on the already-processed argument results. The acceptor + * is $resolvedParametersAcceptor (type-driven, generics resolved by processArgs) + * rather than re-selected from the args on the asking scope. The subject's own + * default narrowing comes from DefaultNarrowingHelper instead of + * TypeSpecifier::handleDefaultTruthyOrFalseyContext(), which would re-enter this + * expression through TypeSpecifier::create(). + * + * @param MethodCall $expr + * @param MethodCall $normalizedExpr + */ + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, Expr $normalizedExpr, ExpressionResult $varResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes { if (!$expr->name instanceof Identifier) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultMethodCallNarrowing($scope, $expr, $varResult, $context); } - $methodCalledOnType = $scope->getType($expr->var); + // the var was processed during processExpr; read its already-computed + // result instead of re-walking via Scope::getType(). + $methodCalledOnType = $varResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); $methodReflection = $scope->getMethodReflection($methodCalledOnType, $expr->name->name); if ($methodReflection !== null) { - // lazy create parametersAcceptor, as creation can be expensive - $parametersAcceptor = null; - - $normalizedExpr = $expr; $args = $expr->getArgs(); - if (count($args) > 0) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); - $normalizedExpr = ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $expr) ?? $expr; - } $referencedClasses = $methodCalledOnType->getObjectClassNames(); if ( @@ -337,7 +480,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e && $this->reflectionProvider->hasClass($referencedClasses[0]) ) { $methodClassReflection = $this->reflectionProvider->getClass($referencedClasses[0]); - foreach ($typeSpecifier->getMethodTypeSpecifyingExtensionsForClass($methodClassReflection->getName()) as $extension) { + foreach ($this->typeSpecifier->getMethodTypeSpecifyingExtensionsForClass($methodClassReflection->getName()) as $extension) { if (!$extension->isMethodSupported($methodReflection, $normalizedExpr, $context)) { continue; } @@ -346,33 +489,87 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } - if (count($args) > 0) { - $specifiedTypes = $typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if (count($args) > 0 && $resolvedParametersAcceptor !== null) { + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } } $assertions = $methodReflection->getAsserts(); - if ($assertions->getAll() !== []) { - $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $args, $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); - + if ($assertions->getAll() !== [] && $resolvedParametersAcceptor !== null) { $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( $type, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $resolvedParametersAcceptor->getResolvedTemplateTypeMap(), + $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) ->setRootExpr($specifiedTypes->getRootExpr()); } } } - return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $this->defaultMethodCallNarrowing($scope, $expr, $varResult, $context); + } + + /** + * The default truthy/falsey narrowing of the call expression itself, gated by + * the same purity check TypeSpecifier::create() applies: a method with side + * effects (or an unknown method whose result is not remembered) is not + * narrowable - calling it twice may yield different values - so it contributes + * no entry. Mirrors create()'s MethodCall handling inside-out, without + * re-entering this expression through create(). + * + * @param MethodCall $expr + */ + private function defaultMethodCallNarrowing(MutatingScope $scope, Expr $expr, ExpressionResult $varResult, TypeSpecifierContext $context): SpecifiedTypes + { + // a truthy chain containing a nullsafe narrows its receivers not-null + // regardless of the call's own narrowability - the old-world truthy + // default routed through create()'s nullsafe fan + $nullsafeFan = null; + if ($context->truthy() && !$context->falsey()) { + $storage = $scope->getCurrentExpressionResultStorage(); + $result = $storage !== null ? $storage->findExpressionResult($expr) : null; + if ($result !== null) { + $nullsafeFan = $this->defaultNarrowingHelper->createNullsafeReceiverOnlyTypes($scope, $expr, $result, StaticTypeFactory::falsey(), TypeSpecifierContext::createFalse()); + } + } + + if (!$this->isMethodCallNarrowable($scope, $expr, $varResult)) { + $base = (new SpecifiedTypes([], []))->setRootExpr($expr); + + return $nullsafeFan !== null ? $base->unionWith($nullsafeFan)->setRootExpr($expr) : $base; + } + + $default = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + + return $nullsafeFan !== null ? $default->unionWith($nullsafeFan)->setRootExpr($expr) : $default; + } + + /** @param MethodCall $expr */ + private function isMethodCallNarrowable(MutatingScope $scope, Expr $expr, ExpressionResult $varResult): bool + { + if (!$expr->name instanceof Identifier) { + return true; + } + + $calledOnType = $varResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + $methodReflection = $scope->getMethodReflection($calledOnType, $expr->name->toString()); + if ($methodReflection === null) { + return false; + } + + $hasSideEffects = $methodReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); } } diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index dea1f2b9e7d..23c29c75a3d 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\GatheringNodeCallback; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -26,7 +27,6 @@ use PHPStan\Analyser\ThrowPoint; use PHPStan\Analyser\Traverser\ConstructorClassTemplateTraverser; use PHPStan\Analyser\Traverser\GenericTypeTemplateTraverser; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredExtensions; use PHPStan\DependencyInjection\AutowiredParameter; @@ -88,6 +88,7 @@ public function __construct( #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -109,6 +110,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; $normalizedExpr = $expr; + $className = null; + $classResult = null; if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); @@ -125,7 +128,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $constructorReflection = $classReflection->getConstructor(); // A structural acceptor (names/positions/variadic) drives argument // normalization and the throw point - generics are resolved - // type-driven by processArgs() into the resolved acceptor. + // type-driven by processArgs() into $resolvedParametersAcceptor. $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); if ($constructorReflection->getDeclaringClass()->getName() === $classReflection->getName()) { @@ -169,9 +172,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } else { $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, $nodeCallback, StatementContext::createTopLevel()); } + + if ($parametersAcceptor !== null) { + $normalizedExpr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; + } } else { $isDynamic = true; - $objectClasses = $scope->getType($expr)->getObjectClassNames(); + + $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $classResult->getScope(); + $hasYield = $classResult->hasYield(); + $throwPoints = $classResult->getThrowPoints(); + $impurePoints = $classResult->getImpurePoints(); + $isAlwaysTerminating = $classResult->isAlwaysTerminating(); + + // The instantiated object type derives from the class expression - read + // its already-processed result rather than asking Scope::getType() for + // the not-yet-stored New_ node, which would re-enter this handler. + $objectClasses = $classResult->getType()->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); if (count($objectClasses) === 1) { $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new New_(new Name($objectClasses[0])), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $className = $objectClasses[0]; @@ -181,12 +199,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $additionalThrowPoints = [InternalThrowPoint::createImplicit($scope, $expr)]; } - $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $classResult->getScope(); - $hasYield = $classResult->hasYield(); - $throwPoints = $classResult->getThrowPoints(); - $impurePoints = $classResult->getImpurePoints(); - $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $throwPoints = array_merge($throwPoints, $additionalThrowPoints); if ($className !== null) { @@ -210,12 +222,54 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $variants = $constructorReflection !== null ? $constructorReflection->getVariants() : []; $namedArgumentsVariants = $constructorReflection !== null ? $constructorReflection->getNamedArgumentsVariants() : null; $argsResult = $nodeScopeResolver->processArgs($stmt, $constructorReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context); + $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); + $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $hasYield = $hasYield || $argsResult->hasYield(); $throwPoints = array_merge($throwPoints, $argsResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + // The new-expression type is derived from $resolvedParametersAcceptor - the + // constructor acceptor processArgs() selected from the arg types gathered on + // the arg-to-arg evolving scope (type-driven, resolves the class's @template + // parameters from constructor args). When null (native-types-promoted, or + // on-demand / synthetic pricing), resolveReturnType() re-selects a structural + // acceptor from the args on the asking scope. + $typeCallback = fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $expr, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $classResult !== null ? ($nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) : null, + ); + $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $expr, + $resolvedParametersAcceptor, + $specifyContext, + ); + + // Store a preliminary result carrying the type/specify callbacks before the + // throw-point return type is computed: getConstructorThrowPoint() and the + // exact-instantiation return type resolution can re-enter on demand (e.g. a + // dynamic static-method return type extension narrowing this very + // instantiation). Without a stored result that narrowing would re-process + // this New_ on demand and recurse. The callbacks are scope-independent, so + // the preliminary result answers those asks correctly; the final result + // finalize() completes it with the resolved scope and throw/impure points. + $preliminaryResult = $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + ); + $nodeScopeResolver->storeExpressionResult($storage, $expr, $preliminaryResult); + if ($constructorReflection !== null && $parametersAcceptor !== null) { $className ??= $constructorReflection->getDeclaringClass()->getName(); $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope, $context); @@ -234,15 +288,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->invalidateVolatileExpressions(); } - return $this->expressionResultFactory->create( - $scope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: $hasYield, - isAlwaysTerminating: $isAlwaysTerminating, - throwPoints: $throwPoints, - impurePoints: $impurePoints, - ); + return $preliminaryResult->finalize($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } /** @@ -261,7 +307,7 @@ private function processConstructorReflection(string $className, New_ $expr, Mut $constructorReflection = $classReflection->getConstructor(); // A structural acceptor (names/positions/variadic) drives argument // normalization and the throw point - generics are resolved - // type-driven by processArgs() into the resolved acceptor. + // type-driven by processArgs() into $resolvedParametersAcceptor. $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); } } @@ -341,10 +387,19 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio return null; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * The stored new-expression type is derived from $preResolvedAcceptor - the + * constructor acceptor processArgs() selected from the arg types gathered on + * the arg-to-arg evolving scope (resolves the class's @template parameters + * from constructor args). Null falls back to re-selecting a structural acceptor + * from the args on the asking scope (on-demand / synthetic pricing). + * + * @param New_ $expr + */ + private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor, ?Type $classExprType): Type { if ($expr->class instanceof Name) { - return $this->exactInstantiation($scope, $expr, $expr->class); + return $this->exactInstantiation($scope, $expr, $expr->class, $preResolvedAcceptor); } if ($expr->class instanceof Node\Stmt\Class_) { $anonymousClassReflection = $this->reflectionProvider->getAnonymousClassReflection($expr->class, $scope); @@ -352,11 +407,15 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return new ObjectType($anonymousClassReflection->getName()); } - $exprType = $scope->getType($expr->class); - return $exprType->getObjectTypeOrClassStringObjectType(); + // the class expression was walked by processExpr; its result's type of + // the asked flavour is passed in by the typeCallback + if ($classExprType === null) { + throw new ShouldNotHappenException(); + } + return $classExprType->getObjectTypeOrClassStringObjectType(); } - private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className): Type + private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className, ?ParametersAcceptor $preResolvedAcceptor): Type { $resolvedClassName = $scope->resolveName($className); $isStatic = false; @@ -402,8 +461,7 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas $node->getArgs(), ); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, + $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::combineVariantsForNormalization( $methodCall->getArgs(), $constructorMethod->getVariants(), $constructorMethod->getNamedArgumentsVariants(), @@ -433,9 +491,21 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return TypeCombinator::union(...$resolvedTypes); } - $methodResult = $scope->getType($methodCall); - if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { - return $methodResult; + // A constructor makes `new` never-returning only when its own return type + // is (or can resolve to) explicit never; the dynamic static-method return + // type extensions already ran above, so only the base return type is left + // to check. Pricing the synthetic StaticCall on demand for this is + // expensive and pointless for the overwhelmingly common plain + // void/object constructor - skip it unless the return type could be never. + $constructorReturnType = $parametersAcceptor->getReturnType(); + if ($constructorReturnType instanceof NeverType || $constructorReturnType->hasTemplateOrLateResolvableType()) { + // $methodCall is a synthetic StaticCall the handler built - it is not + // a source node, so Scope::getType() prices it on demand (the + // constructor's own never-returning conditional return type). + $methodResult = $scope->getType($methodCall); + if ($methodResult instanceof NeverType && $methodResult->isExplicit()) { + return $methodResult; + } } $objectType = $isStatic ? new StaticType($classReflection) : new ObjectType($resolvedClassName, classReflection: $classReflection); @@ -638,13 +708,24 @@ classReflection: $classReflection->withTypes($types)->asFinal(), return TypeTraverser::map($newGenericType, new GenericTypeTemplateTraverser($resolvedTemplateTypeMap)); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the + * constructor's @phpstan-assert narrowing is invoked on the already-processed + * argument results. The acceptor is $resolvedParametersAcceptor (type-driven, + * generics resolved by processArgs) rather than re-selected from the args on + * the asking scope. The subject's own default narrowing comes from + * DefaultNarrowingHelper instead of TypeSpecifier::specifyDefaultTypes(), which + * would re-enter this expression through TypeSpecifier::create(). + * + * @param New_ $expr + */ + private function specifyTypes(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes { if ( !$expr->class instanceof Name || !$this->reflectionProvider->hasClass($expr->class->toString()) ) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } $classReflection = $this->reflectionProvider->getClass($expr->class->toString()); @@ -653,17 +734,15 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e $methodReflection = $classReflection->getConstructor(); $asserts = $methodReflection->getAsserts(); - if ($asserts->getAll() !== []) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $expr->getArgs(), $methodReflection->getVariants(), $methodReflection->getNamedArgumentsVariants()); - + if ($asserts->getAll() !== [] && $resolvedParametersAcceptor !== null) { $asserts = $asserts->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( $type, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $resolvedParametersAcceptor->getResolvedTemplateTypeMap(), + $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; @@ -671,6 +750,10 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } + // A known class without (applicable) constructor asserts contributes no + // narrowing entry, mirroring the old handler's empty return for this path + // (a `new X()` is always a truthy object, so the default truthy/falsey + // removal that path 1 emits would be a no-op here anyway). return (new SpecifiedTypes([], []))->setRootExpr($expr); } diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index a10009c2d56..53cfc2582eb 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -14,14 +14,16 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\FunctionCallableNode; +use PHPStan\Node\MethodCallableNode; use PHPStan\Node\Printer\ExprPrinter; +use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Parser\ReversePipeTransformerVisitor; use PHPStan\Type\Type; use function array_merge; @@ -33,7 +35,10 @@ final class PipeHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -42,59 +47,40 @@ public function supports(Expr $expr): bool return $expr instanceof Pipe; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->right instanceof FuncCall && $expr->right->isFirstClassCallable()) { - return $scope->getType(new FuncCall($expr->right->name, [ - new Arg($expr->left), - ])); - } elseif ($expr->right instanceof MethodCall && $expr->right->isFirstClassCallable()) { - return $scope->getType(new MethodCall($expr->right->var, $expr->right->name, [ - new Arg($expr->left), - ])); - } elseif ($expr->right instanceof StaticCall && $expr->right->isFirstClassCallable()) { - return $scope->getType(new StaticCall($expr->right->class, $expr->right->name, [ - new Arg($expr->left), - ])); - } - - return $scope->getType(new FuncCall($expr->right, [ - new Arg($expr->left), - ])); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $rightAttributes = array_merge($expr->right->getAttributes(), ['virtualPipeOperatorCall' => true]); unset($rightAttributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $argAttributes = $expr->getAttribute(ReversePipeTransformerVisitor::ARG_ATTRIBUTES_NAME, []); - $isRightFirstClassCallable = false; + $firstClassCallableNode = null; if ($expr->right instanceof FuncCall && $expr->right->isFirstClassCallable()) { $callExpr = new FuncCall($expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); - $isRightFirstClassCallable = true; + $firstClassCallableNode = new FunctionCallableNode($expr->right->name, $expr->right); } elseif ($expr->right instanceof MethodCall && $expr->right->isFirstClassCallable()) { $callExpr = new MethodCall($expr->right->var, $expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); - $isRightFirstClassCallable = true; + $firstClassCallableNode = new MethodCallableNode($expr->right->var, $expr->right->name, $expr->right); } elseif ($expr->right instanceof StaticCall && $expr->right->isFirstClassCallable()) { $callExpr = new StaticCall($expr->right->class, $expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); - $isRightFirstClassCallable = true; + $firstClassCallableNode = new StaticMethodCallableNode($expr->right->class, $expr->right->name, $expr->right); } else { $callExpr = new FuncCall($expr->right, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); } - if ($isRightFirstClassCallable) { - // the original first-class callable node is not processed through - // processExprNode - store its result so that node callbacks asking - // about its type can be resumed + if ($firstClassCallableNode !== null) { + // store a result for $expr->right so node callbacks asking about its + // type can be resumed. Its closure type lives on the matching + // *CallableNode, processed here (storage is available, so the result - + // not the storage - is captured) and read back in the typeCallback. + $callableNodeResult = $nodeScopeResolver->processExprOnDemand($firstClassCallableNode, $scope, $storage); $nodeScopeResolver->storeExpressionResult($storage, $expr->right, $this->expressionResultFactory->create( $scope, beforeScope: $scope, @@ -103,6 +89,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $callableNodeResult->getNativeType() : $callableNodeResult->getType()), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), )); } @@ -116,12 +104,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $callResult->isAlwaysTerminating(), throwPoints: $callResult->getThrowPoints(), impurePoints: $callResult->getImpurePoints(), + // the pipe evaluates to its rewritten call - read that child's result + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $callResult->getNativeType() : $callResult->getType()), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 606248f72a9..2da101609e9 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -3,30 +3,28 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\New_; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Identifier; use PhpParser\Node\Name; -use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; @@ -36,8 +34,10 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\Generic\TemplateTypeHelper; use PHPStan\Type\Generic\TemplateTypeVariance; @@ -65,13 +65,15 @@ final class StaticCallHandler implements ExprHandler { public function __construct( - private EarlyTerminatingCallHelper $earlyTerminatingCallHelper, private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, private MethodThrowPointHelper $methodThrowPointHelper, private ReflectionProvider $reflectionProvider, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { } @@ -89,6 +91,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; $containsNullsafe = false; + $classResult = null; + $nameResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -100,6 +104,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $containsNullsafe = $classResult->containsNullsafe(); } + // A static call configured as early-terminating never returns: give it an + // explicit never so the statement's exit point follows from the result type, + // instead of NodeScopeResolver re-deriving it via Scope::getType(). + $isEarlyTerminating = false; + if ($expr->name instanceof Identifier) { + $earlyTerminatingClassType = $expr->class instanceof Name + ? $scope->resolveTypeByName($expr->class) + : $classResult->getType(); + $isEarlyTerminating = $this->earlyTerminatingHelper->isEarlyTerminatingMethodCall($expr->name->name, $earlyTerminatingClassType); + } + $isAlwaysTerminating = $isAlwaysTerminating || $isEarlyTerminating; + $parametersAcceptor = null; $variants = []; $namedArgumentsVariants = null; @@ -107,7 +123,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $closureBindScopeFactory = null; if ($expr->name instanceof Identifier) { if ($expr->class instanceof Name) { - $classType = $scope->resolveTypeByName($expr->class); + // the acceptor selected here feeds the call's return type - a + // STATIC method called through an explicit class name binds + // `static` to that class, so select from the demoted type + $classType = $this->resolveTypeByNameWithLateStaticBinding($scope, $expr->class, $expr->name->name); $methodName = $expr->name->name; if ($classType->hasMethod($methodName)->yes()) { $methodReflection = $classType->getMethod($methodName, $scope); @@ -123,9 +142,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $declaringClass->getName() === 'Closure' && strtolower($methodName) === 'bind' ) { - // deferred until the closure argument is processed: with - // closures processed last, the bound $this/scope arguments - // are already evaluated on the scope the factory receives $closureBindScopeFactory = static function (MutatingScope $boundScope) use ($expr): MutatingScope { $thisType = null; $nativeThisType = null; @@ -169,7 +185,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } } elseif ($expr->class instanceof Expr) { - $classType = $scope->getType($expr->class)->getObjectTypeOrClassStringObjectType(); + // the class expr was processed above as the receiver; read its + // already-computed result instead of re-walking via Scope::getType(). + // A nullsafe receiver's null is the chain short-circuit, not a + // callee - strip it before the reflection lookup, like the + // return-type resolution does. + $classType = TypeCombinator::removeNull($classResult->getType())->getObjectTypeOrClassStringObjectType(); $methodName = $expr->name->name; $methodReflection = $scope->getMethodReflection($classType, $methodName); if ($methodReflection !== null) { @@ -187,7 +208,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($expr->class instanceof Expr) { - $objectClasses = $scope->getType($expr->class)->getObjectClassNames(); + // the class expr was processed above as the receiver; read its + // already-computed result instead of re-walking via Scope::getType(). + $objectClasses = $classResult->getType()->getObjectClassNames(); if (count($objectClasses) !== 1) { $objectClasses = $scope->getType(new New_($expr->class))->getObjectClassNames(); } @@ -226,18 +249,90 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $variants, $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScopeFactory); $resolvedParametersAcceptor = $argsResult->getResolvedParametersAcceptor(); $scope = $argsResult->getScope(); + $nodeScopeResolver->processDroppedArgs($stmt, $expr, $normalizedExpr, $scope, $storage, $context); $scopeFunction = $scope->getFunction(); + // The early structural check above only sees the unresolved acceptor return + // type; a conditional-return never (e.g. `($x is Foo ? never : string)`) + // only resolves to never once the actual argument types are folded in by the + // type-driven resolved acceptor. + if ($resolvedParametersAcceptor !== null) { + $resolvedReturnType = $resolvedParametersAcceptor->getReturnType(); + $isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit()); + } + + // The return type is derived from $resolvedParametersAcceptor - the acceptor + // processArgs() selected from the arg types gathered on the arg-to-arg + // evolving scope (type-driven, generics resolved). When null + // (native-types-promoted, or on-demand / synthetic pricing) the acceptor is + // re-derived from the already-processed argument results on the asking scope. + $typeCallback = $isEarlyTerminating + ? static fn (bool $nativeTypesPromoted): Type => new NeverType(true) + : fn (bool $nativeTypesPromoted): Type => $this->resolveReturnType( + $nodeScopeResolver, + $beforeScope, + $nativeTypesPromoted, + $expr, + $classResult, + $nameResult, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + $argsResult, + ); + $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $expr, + $normalizedExpr, + $classResult, + $resolvedParametersAcceptor, + $specifyContext, + ); + + // A type constraint on a (narrowable, i.e. non-side-effecting) static call + // narrows the call itself - the inside-out equivalent of createForExpr's + // StaticCall purity gate + tail entry. An impure call narrows to nothing. + $createTypesCallback = function (Type $type, TypeSpecifierContext $createContext, bool $nativeTypesPromoted) use ($expr, $classResult, $nodeScopeResolver, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + return $this->isStaticCallNarrowable($s, $expr, $classResult, $nodeScopeResolver) + ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) + : new SpecifiedTypes([], []); + }; + + // Store a preliminary result carrying the type/specify callbacks before the + // throw point is computed: the method throw point resolves the return type + // (resolveReturnType below) through dynamic static-method return type + // extensions, which can narrow this very call on demand. Without a stored + // result that narrowing would re-process this StaticCall on demand and + // recurse. The callbacks are scope-independent, so the preliminary result + // answers those asks correctly; finalize() below completes it with the + // resolved scope and throw/impure points. + $preliminaryResult = $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + containsNullsafe: $containsNullsafe, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, + ); + $nodeScopeResolver->storeExpressionResult($storage, $expr, $preliminaryResult); + if ($methodReflection !== null) { - // The early structural check above only sees the unresolved acceptor - // return type; a conditional-return never (e.g. `($x is Foo ? never : - // string)`) only resolves to never once the actual argument types are - // folded in by the type-driven resolved acceptor. - if ($resolvedParametersAcceptor !== null) { - $resolvedReturnType = $resolvedParametersAcceptor->getReturnType(); - $isAlwaysTerminating = $isAlwaysTerminating || ($resolvedReturnType instanceof NeverType && $resolvedReturnType->isExplicit()); - } - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + // The call's return type, computed from the already-processed argument + // results (resolveReturnType reads them via the class/name results, + // never re-running processArgs) - asking + // Scope::getType() for the StaticCall here would re-enter this handler on + // demand, as its final result is not stored yet. + // Resolve it through the stored preliminary result so the memoized + // value seeds the final result below - the first later type read + // would otherwise run resolveReturnType() again. + $staticCallReturnType = $preliminaryResult->getKeepVoidType(false); + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $staticCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -310,131 +405,140 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - return $this->expressionResultFactory->create( - $scope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: $hasYield, - isAlwaysTerminating: $isAlwaysTerminating, - throwPoints: $throwPoints, - impurePoints: $impurePoints, - containsNullsafe: $containsNullsafe, - ); + return $preliminaryResult->finalize($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * The call-expression type is derived from $preResolvedAcceptor - the acceptor + * processArgs() selected from the arg types gathered on the arg-to-arg evolving + * scope (type-driven, generics resolved). When null (native-types-promoted, or + * on-demand / synthetic pricing) it falls back to re-selecting from the args via + * MethodCallReturnTypeHelper on the asking scope. + * + * The class/name were processed during processExpr; their already computed + * results are read instead of re-walking via Scope::getType(). The dynamic-name + * branch builds a synthetic StaticCall priced on demand by the resolver. + * + */ + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, StaticCall $expr, ?ExpressionResult $classResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ?ArgsResult $argsResult): Type { - if ($expr->name instanceof Identifier) { - $earlyTerminatingClassType = $expr->class instanceof Name - ? $scope->resolveTypeByName($expr->class) - : $scope->getType($expr->class); - if ($this->earlyTerminatingCallHelper->isEarlyTerminatingMethodCall($expr->name->name, $earlyTerminatingClassType)) { - return new NeverType(true); - } - } - - if ($expr->name instanceof Identifier) { - if ($scope->nativeTypesPromoted) { + $classType = $classResult !== null + ? ($nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) + : null; + // a call on a nullsafe chain whose class-receiver is currently nullable + // short-circuits to null - the class result carries whether the chain + // contains a ?-> (a plain nullable receiver does not propagate). + $shortCircuit = static fn (Type $type): Type => $expr->class instanceof Expr + && $classResult !== null + && $classResult->containsNullsafe() + && $classType !== null + && TypeCombinator::containsNull($classType) + ? TypeCombinator::addNull($type) + : $type; + + // the method reflection and dynamic-return-type extensions run on the + // reflection scope (the lexical context / beforeScope); the class- + // expression type is read from the operand result above. + $resolveStaticMethod = function (string $methodName, StaticCall $staticCall) use ($reflectionScope, $nativeTypesPromoted, $classType, $expr, $preResolvedAcceptor, $argsResult): Type { + if ($nativeTypesPromoted) { if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($scope, $expr->class, $expr->name); + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($reflectionScope, $expr->class, $methodName); } else { - $staticMethodCalledOnType = $scope->getNativeType($expr->class); + if ($classType === null) { + throw new ShouldNotHappenException(); + } + $staticMethodCalledOnType = $classType; } - $methodReflection = $scope->getMethodReflection( - $staticMethodCalledOnType, - $expr->name->name, - ); + $methodReflection = $reflectionScope->getMethodReflection($staticMethodCalledOnType, $methodName); if ($methodReflection === null) { - $callType = new ErrorType(); - } else { - $callType = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); - } - - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $callType); + return new ErrorType(); } - return $callType; + return ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType(); } if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($scope, $expr->class, $expr->name); + $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($reflectionScope, $expr->class, $methodName); } else { - $staticMethodCalledOnType = TypeCombinator::removeNull($scope->getType($expr->class))->getObjectTypeOrClassStringObjectType(); + if ($classType === null) { + throw new ShouldNotHappenException(); + } + $staticMethodCalledOnType = TypeCombinator::removeNull($classType)->getObjectTypeOrClassStringObjectType(); } - $callType = $this->methodCallReturnTypeHelper->methodCallReturnType( - $scope, + return $this->methodCallReturnTypeHelper->methodCallReturnType( + $reflectionScope, $staticMethodCalledOnType, - $expr->name->toString(), - $expr, - ); - if ($callType === null) { - $callType = new ErrorType(); - } + $methodName, + $staticCall, + $preResolvedAcceptor, + $argsResult, + ) ?? new ErrorType(); + }; - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $callType); - } + if ($expr->name instanceof Identifier) { + return $shortCircuit($resolveStaticMethod($expr->name->toString(), $expr)); + } - return $callType; + // dynamic static call Foo::{$name}(): resolve each possible name on the + // reflection scope. The asking scope is not narrowed per name, so such + // calls can be less precise. + if ($nameResult === null) { + throw new ShouldNotHappenException(); } - $nameType = $scope->getType($expr->name); + $nameType = $nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType(); if (count($nameType->getConstantStrings()) > 0) { return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $scope - ->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))) - ->getType(new Expr\StaticCall($expr->class, new Identifier($constantString->getValue()), $expr->args)), $nameType->getConstantStrings()), - ); - } - - return new MixedType(); - } - - private function resolveTypeByNameWithLateStaticBinding(MutatingScope $scope, Name $class, Identifier $name): TypeWithClassName - { - $classType = $scope->resolveTypeByName($class); + ...array_map(static function ($constantString) use ($expr, $resolveStaticMethod): Type { + if ($constantString->getValue() === '') { + return new ErrorType(); + } - if ( - $classType instanceof StaticType - && !in_array($class->toLowerString(), ['self', 'static', 'parent'], true) - ) { - $methodReflectionCandidate = $scope->getMethodReflection( - $classType, - $name->name, + return $resolveStaticMethod( + $constantString->getValue(), + new StaticCall($expr->class, new Identifier($constantString->getValue()), $expr->args), + ); + }, $nameType->getConstantStrings()), ); - if ($methodReflectionCandidate !== null && $methodReflectionCandidate->isStatic()) { - $classType = $classType->getStaticObjectType(); - } } - return $classType; + return new MixedType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * Ported inside-out from the old TypeResolvingExprHandler::specifyTypes(): the + * StaticMethodTypeSpecifyingExtensions, conditional-return-type and assert + * narrowing are invoked on the already-processed argument + * results. The acceptor is $resolvedParametersAcceptor (type-driven, generics + * resolved by processArgs) rather than re-selected from the args on the asking + * scope. The subject's own default narrowing comes from DefaultNarrowingHelper + * instead of TypeSpecifier::handleDefaultTruthyOrFalseyContext(), which would + * re-enter this expression through TypeSpecifier::create(). + * + * @param StaticCall $expr + * @param StaticCall $normalizedExpr + */ + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, Expr $normalizedExpr, ?ExpressionResult $classResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes { if (!$expr->name instanceof Identifier) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); } if ($expr->class instanceof Name) { $calleeType = $scope->resolveTypeByName($expr->class); } else { - $calleeType = $scope->getType($expr->class); + // the class expr was processed during processExpr; its result is + // always captured for an expression class + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $calleeType = $classResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); } $staticMethodReflection = $scope->getMethodReflection($calleeType, $expr->name->name); if ($staticMethodReflection !== null) { - // lazy create parametersAcceptor, as creation can be expensive - $parametersAcceptor = null; - - $normalizedExpr = $expr; $args = $expr->getArgs(); - if (count($args) > 0) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs($scope, $args, $staticMethodReflection->getVariants(), $staticMethodReflection->getNamedArgumentsVariants()); - $normalizedExpr = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $expr) ?? $expr; - } $referencedClasses = $calleeType->getObjectClassNames(); if ( @@ -442,7 +546,7 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e && $this->reflectionProvider->hasClass($referencedClasses[0]) ) { $staticMethodClassReflection = $this->reflectionProvider->getClass($referencedClasses[0]); - foreach ($typeSpecifier->getStaticMethodTypeSpecifyingExtensionsForClass($staticMethodClassReflection->getName()) as $extension) { + foreach ($this->typeSpecifier->getStaticMethodTypeSpecifyingExtensionsForClass($staticMethodClassReflection->getName()) as $extension) { if (!$extension->isStaticMethodSupported($staticMethodReflection, $normalizedExpr, $context)) { continue; } @@ -451,33 +555,105 @@ public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $e } } - if (count($args) > 0) { - $specifiedTypes = $typeSpecifier->specifyTypesFromConditionalReturnType($context, $expr, $parametersAcceptor, $scope); + if (count($args) > 0 && $resolvedParametersAcceptor !== null) { + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromConditionalReturnType($context, $expr, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; } } $assertions = $staticMethodReflection->getAsserts(); - if ($assertions->getAll() !== []) { - $parametersAcceptor ??= ParametersAcceptorSelector::selectFromArgs($scope, $args, $staticMethodReflection->getVariants(), $staticMethodReflection->getNamedArgumentsVariants()); - + if ($assertions->getAll() !== [] && $resolvedParametersAcceptor !== null) { $asserts = $assertions->mapTypes(static fn (Type $type) => TemplateTypeHelper::resolveTemplateTypes( $type, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $resolvedParametersAcceptor->getResolvedTemplateTypeMap(), + $resolvedParametersAcceptor instanceof ExtendedParametersAcceptor ? $resolvedParametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createInvariant(), )); - $specifiedTypes = $typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $parametersAcceptor, $scope); + $specifiedTypes = $this->defaultNarrowingHelper->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes - ->unionWith($typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope)) + ->unionWith($this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context)) ->setRootExpr($specifiedTypes->getRootExpr()); } } } - return $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); + return $this->defaultStaticCallNarrowing($scope, $expr, $classResult, $nodeScopeResolver, $context); + } + + /** + * The default truthy/falsey narrowing of the call expression itself, gated by + * the same purity check TypeSpecifier::create() applies: a static method with + * side effects (or an unknown method whose result is not remembered) is not + * narrowable - calling it twice may yield different values - so it contributes + * no entry. Mirrors create()'s StaticCall handling inside-out, without + * re-entering this expression through create(). + * + * @param StaticCall $expr + */ + private function defaultStaticCallNarrowing(MutatingScope $scope, Expr $expr, ?ExpressionResult $classResult, NodeScopeResolver $nodeScopeResolver, TypeSpecifierContext $context): SpecifiedTypes + { + if (!$this->isStaticCallNarrowable($scope, $expr, $classResult, $nodeScopeResolver)) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + /** @param StaticCall $expr */ + private function isStaticCallNarrowable(MutatingScope $scope, Expr $expr, ?ExpressionResult $classResult, NodeScopeResolver $nodeScopeResolver): bool + { + if (!$expr->name instanceof Identifier) { + return true; + } + + if ($expr->class instanceof Name) { + $calleeType = $scope->resolveTypeByName($expr->class); + } else { + if ($classResult === null) { + throw new ShouldNotHappenException(); + } + $calleeType = $classResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + $methodReflection = $scope->getMethodReflection($calleeType, $expr->name->toString()); + if ($methodReflection === null) { + return false; + } + + $hasSideEffects = $methodReflection->hasSideEffects(); + if ($hasSideEffects->yes()) { + return false; + } + + return $this->rememberPossiblyImpureFunctionValues || $hasSideEffects->no(); + } + + /** + * An explicit class name within the current hierarchy resolves to a + * StaticType, but calling a STATIC method through it binds `static` to the + * named class - demote to the plain object type so `A::retStatic()` is `A`, + * not `static(self)`. self/static/parent keep late static binding. + */ + private function resolveTypeByNameWithLateStaticBinding(MutatingScope $scope, Name $class, string $methodName): TypeWithClassName + { + $classType = $scope->resolveTypeByName($class); + + if ( + $classType instanceof StaticType + && !in_array($class->toLowerString(), ['self', 'static', 'parent'], true) + ) { + $methodReflectionCandidate = $scope->getMethodReflection( + $classType, + $methodName, + ); + if ($methodReflectionCandidate !== null && $methodReflectionCandidate->isStatic()) { + $classType = $classType->getStaticObjectType(); + } + } + + return $classType; } } diff --git a/tests/PHPStan/Analyser/nsrt/arrow-function-call-arg-type.php b/tests/PHPStan/Analyser/nsrt/arrow-function-call-arg-type.php new file mode 100644 index 00000000000..a3c2e10d979 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/arrow-function-call-arg-type.php @@ -0,0 +1,18 @@ + 1); + assertType('array{static-Closure(): 1}', $viaArrow); + + $viaClosure = []; + array_push($viaClosure, static function (): int { + return 1; + }); + assertType('array{static-Closure(): 1}', $viaClosure); +} diff --git a/tests/PHPStan/Analyser/nsrt/precise-scope-select-from-args.php b/tests/PHPStan/Analyser/nsrt/precise-scope-select-from-args.php new file mode 100644 index 00000000000..989c34a46b3 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/precise-scope-select-from-args.php @@ -0,0 +1,28 @@ + Date: Fri, 14 Aug 2026 19:11:34 +0200 Subject: [PATCH 10/32] Read impossible-check verdicts from the call's own result ImpossibleCheckTypeHelper stops re-specifying the condition through TypeSpecifier: the three call virtual nodes carry the call's ExpressionResult, the rules read the narrowing verdict from it, and argument types come from the ArgsResult when available. The TypeSpecifier constructor dependency is gone, which also removes the argument from the 16 rule test constructors. TypeSpecifyingFunctionsDynamicReturnTypeExtension is deleted: the always-true/false collapse for array_key_exists()/key_exists()/ in_array()/is_subclass_of() lives in FuncCallHandler's typeCallback, reading its own stored result through a weak reference (a strong backedge would be an uncollectable cycle under gc_disable()). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Node/FunctionCallExpressionNode.php | 20 +++++-- src/Node/MethodCallExpressionNode.php | 19 +++++-- src/Node/StaticMethodCallExpressionNode.php | 19 +++++-- .../ImpossibleCheckTypeFunctionCallRule.php | 7 ++- .../Comparison/ImpossibleCheckTypeHelper.php | 57 ++++++++++++++----- .../ImpossibleCheckTypeMethodCallRule.php | 7 ++- ...mpossibleCheckTypeStaticMethodCallRule.php | 7 ++- .../BooleanAndConstantConditionRuleTest.php | 3 - .../BooleanNotConstantConditionRuleTest.php | 3 - .../BooleanOrConstantConditionRuleTest.php | 3 - .../DoWhileLoopConstantConditionRuleTest.php | 3 - .../ElseIfConstantConditionRuleTest.php | 3 - .../IfConstantConditionRuleTest.php | 3 - ...mpossibleCheckTypeFunctionCallRuleTest.php | 1 - ...sibleCheckTypeGenericOverwriteRuleTest.php | 1 - ...sibleCheckTypeMethodCallRuleEqualsTest.php | 1 - .../ImpossibleCheckTypeMethodCallRuleTest.php | 1 - ...sibleCheckTypeStaticMethodCallRuleTest.php | 1 - .../LogicalXorConstantConditionRuleTest.php | 3 - .../Comparison/MatchExpressionRuleTest.php | 3 - ...rnaryOperatorConstantConditionRuleTest.php | 3 - .../WhileLoopAlwaysFalseConditionRuleTest.php | 3 - .../WhileLoopAlwaysTrueConditionRuleTest.php | 3 - 23 files changed, 98 insertions(+), 76 deletions(-) diff --git a/src/Node/FunctionCallExpressionNode.php b/src/Node/FunctionCallExpressionNode.php index 59e21ba61c4..0da813dce79 100644 --- a/src/Node/FunctionCallExpressionNode.php +++ b/src/Node/FunctionCallExpressionNode.php @@ -5,19 +5,24 @@ use Override; use PhpParser\Node\Expr\FuncCall; use PhpParser\NodeAbstract; +use PHPStan\Analyser\ExpressionResult; /** - * Emitted by NodeScopeResolver once the call has been processed and stored, so - * rules listening on it (e.g. the impossible-check rules) run on the fully - * processed call instead of asking the scope to specify its types before the - * call node itself is processed. + * Emitted by NodeScopeResolver once a (non-first-class) function call has been + * processed and stored, so impossible-check rules read the call's already-computed + * specified types (via specifyTypesOfNewWorldHandlerNode on the now-processed call, + * or the carried ExpressionResult) instead of asking the scope to specify them + * before the call node itself is processed. * * @internal */ final class FunctionCallExpressionNode extends NodeAbstract implements VirtualNode { - public function __construct(private FuncCall $originalNode) + public function __construct( + private FuncCall $originalNode, + private ExpressionResult $result, + ) { parent::__construct($originalNode->getAttributes()); } @@ -27,6 +32,11 @@ public function getOriginalNode(): FuncCall return $this->originalNode; } + public function getResult(): ExpressionResult + { + return $this->result; + } + #[Override] public function getType(): string { diff --git a/src/Node/MethodCallExpressionNode.php b/src/Node/MethodCallExpressionNode.php index e2be8de06b2..25c4ec1a254 100644 --- a/src/Node/MethodCallExpressionNode.php +++ b/src/Node/MethodCallExpressionNode.php @@ -5,19 +5,23 @@ use Override; use PhpParser\Node\Expr\MethodCall; use PhpParser\NodeAbstract; +use PHPStan\Analyser\ExpressionResult; /** - * Emitted by NodeScopeResolver once the call has been processed and stored, so - * rules listening on it (e.g. the impossible-check rules) run on the fully - * processed call instead of asking the scope to specify its types before the - * call node itself is processed. + * Emitted by NodeScopeResolver once a (non-first-class) method call has been + * processed and stored, so impossible-check rules read the call's already-computed + * specified types from the carried ExpressionResult instead of asking the scope to + * specify them before the call node itself is processed. * * @internal */ final class MethodCallExpressionNode extends NodeAbstract implements VirtualNode { - public function __construct(private MethodCall $originalNode) + public function __construct( + private MethodCall $originalNode, + private ExpressionResult $result, + ) { parent::__construct($originalNode->getAttributes()); } @@ -27,6 +31,11 @@ public function getOriginalNode(): MethodCall return $this->originalNode; } + public function getResult(): ExpressionResult + { + return $this->result; + } + #[Override] public function getType(): string { diff --git a/src/Node/StaticMethodCallExpressionNode.php b/src/Node/StaticMethodCallExpressionNode.php index 2e57a1d8cb5..d5a24d1b1d4 100644 --- a/src/Node/StaticMethodCallExpressionNode.php +++ b/src/Node/StaticMethodCallExpressionNode.php @@ -5,19 +5,23 @@ use Override; use PhpParser\Node\Expr\StaticCall; use PhpParser\NodeAbstract; +use PHPStan\Analyser\ExpressionResult; /** - * Emitted by NodeScopeResolver once the call has been processed and stored, so - * rules listening on it (e.g. the impossible-check rules) run on the fully - * processed call instead of asking the scope to specify its types before the - * call node itself is processed. + * Emitted by NodeScopeResolver once a (non-first-class) static-method call has been + * processed and stored, so impossible-check rules read the call's already-computed + * specified types from the carried ExpressionResult instead of asking the scope to + * specify them before the call node itself is processed. * * @internal */ final class StaticMethodCallExpressionNode extends NodeAbstract implements VirtualNode { - public function __construct(private StaticCall $originalNode) + public function __construct( + private StaticCall $originalNode, + private ExpressionResult $result, + ) { parent::__construct($originalNode->getAttributes()); } @@ -27,6 +31,11 @@ public function getOriginalNode(): StaticCall return $this->originalNode; } + public function getResult(): ExpressionResult + { + return $this->result; + } + #[Override] public function getType(): string { diff --git a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php index 76b470f668a..03eb09ccc96 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php @@ -44,13 +44,14 @@ public function getNodeType(): string public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $funcCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); if (!$funcCall->name instanceof Node\Name) { return []; } $functionName = (string) $funcCall->name; $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $funcCall, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $funcCall, $nodeResult, null, $reasons); if ($isAlways === null) { $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $funcCall); return []; @@ -58,7 +59,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $funcCall); - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $funcCall, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $funcCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } @@ -67,7 +68,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $funcCall, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $funcCall, $nodeResult, null, $reasons); if ($isAlways !== null) { return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 13f62f78cb6..01974248dba 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -8,9 +8,10 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; +use PHPStan\Analyser\ArgsResult; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -48,7 +49,6 @@ final class ImpossibleCheckTypeHelper public function __construct( private ReflectionProvider $reflectionProvider, - private TypeSpecifier $typeSpecifier, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, ) @@ -62,10 +62,12 @@ public function __construct( public function findSpecifiedType( Scope $scope, Expr $node, + ExpressionResult $nodeResult, + ?ArgsResult $argsResult = null, array &$reasons = [], ): ?bool { - $specifiedValue = $this->getSpecifiedType($scope, $node, $reasons); + $specifiedValue = $this->getSpecifiedType($scope, $node, $reasons, $nodeResult, $argsResult); $reasons = array_values(array_unique($reasons)); /** @@ -91,13 +93,41 @@ public function findSpecifiedType( return $specifiedValue; } + /** + * Reads an argument's type from its already-computed ExpressionResult when the + * caller passed the call's ArgsResult (the engine-side verdict in + * FuncCallHandler); rule-side callers read through the scope, whose asks are + * answered from stored results. + */ + private function getArgumentType(Scope $scope, ?ArgsResult $argsResult, Expr $expr, bool $phpDocFlavour = false): Type + { + if ($argsResult !== null && $scope instanceof MutatingScope) { + $argResult = $argsResult->getArgResult($expr); + if ($argResult !== null) { + $native = $phpDocFlavour + ? $scope->nativeTypesPromoted + : (!$this->treatPhpDocTypesAsCertain || $scope->nativeTypesPromoted); + + return $argResult->getTypeOnScope($scope, $native); + } + } + + if ($phpDocFlavour || $this->treatPhpDocTypesAsCertain) { + return $scope->getType($expr); + } + + return $scope->getNativeType($expr); + } + /** * @param list $reasons */ private function getSpecifiedType( Scope $scope, Expr $node, - array &$reasons = [], + array &$reasons, + ExpressionResult $nodeResult, + ?ArgsResult $argsResult, ): ?bool { if ($node instanceof FuncCall) { @@ -129,7 +159,7 @@ private function getSpecifiedType( return null; } elseif ($functionName === 'in_array' && $argsCount >= 2) { $haystackArg = $args[1]->value; - $haystackType = $this->treatPhpDocTypesAsCertain ? $scope->getType($haystackArg) : $scope->getNativeType($haystackArg); + $haystackType = $this->getArgumentType($scope, $argsResult, $haystackArg); if ($haystackType instanceof MixedType) { return null; } @@ -139,11 +169,11 @@ private function getSpecifiedType( } $needleArg = $args[0]->value; - $needleType = $this->treatPhpDocTypesAsCertain ? $scope->getType($needleArg) : $scope->getNativeType($needleArg); + $needleType = $this->getArgumentType($scope, $argsResult, $needleArg); $isStrictComparison = false; if ($argsCount >= 3) { - $strictNodeType = $scope->getType($args[2]->value); + $strictNodeType = $this->getArgumentType($scope, $argsResult, $args[2]->value, true); $isStrictComparison = $strictNodeType->isTrue()->yes(); } @@ -315,7 +345,11 @@ private function getSpecifiedType( } $typeSpecifierScope = $this->treatPhpDocTypesAsCertain ? $scope : $scope->doNotTreatPhpDocTypesAsCertain(); - $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($typeSpecifierScope, $node, $this->determineContext($typeSpecifierScope, $node)); + $typeSpecifierContext = $this->determineContext($typeSpecifierScope, $node); + // the condition expression was already analysed; read its narrowing straight + // from its already-computed ExpressionResult instead of asking the scope + // to specify it again. + $specifiedTypes = $nodeResult->getSpecifiedTypesForScope($typeSpecifierScope, $typeSpecifierContext); // don't validate types on overwrite if ($specifiedTypes->shouldOverwrite()) { @@ -375,11 +409,7 @@ private function getSpecifiedType( continue; } - if ($this->treatPhpDocTypesAsCertain) { - $argumentType = $scope->getType($sureType[0]); - } else { - $argumentType = $scope->getNativeType($sureType[0]); - } + $argumentType = $this->getArgumentType($scope, $argsResult, $sureType[0]); /** @var Type $resultType */ $resultType = $sureType[1]; @@ -505,7 +535,6 @@ public function doNotTreatPhpDocTypesAsCertain(): self return new self( $this->reflectionProvider, - $this->typeSpecifier, false, ); } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php index c5236e38366..028f2e7f57a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php @@ -47,13 +47,14 @@ public function getNodeType(): string public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $methodCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); if (!$methodCall->name instanceof Node\Identifier) { return []; } $methodName = $methodCall->name->name; $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $methodCall, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $methodCall, $nodeResult, null, $reasons); if ($isAlways === null) { $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $methodCall); return []; @@ -61,7 +62,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $methodCall); - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $methodCall, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $methodCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } @@ -70,7 +71,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $methodCall); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $methodCall, $nodeResult, null); if ($isAlways !== null) { return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php index 7189947acdb..09c7a4e3fc3 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php @@ -47,13 +47,14 @@ public function getNodeType(): string public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $staticCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); if (!$staticCall->name instanceof Node\Identifier) { return []; } $methodName = $staticCall->name->name; $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $staticCall, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $staticCall, $nodeResult, null, $reasons); if ($isAlways === null) { $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $staticCall); return []; @@ -61,7 +62,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $staticCall); - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $staticCall, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $staticCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } @@ -70,7 +71,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $staticCall); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $staticCall, $nodeResult, null); if ($isAlways !== null) { return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); } diff --git a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php index 9967649a4c8..81e2aea0a72 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php @@ -36,7 +36,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -49,7 +48,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -62,7 +60,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php index 5e5cbe633d2..438b542441f 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php @@ -35,7 +35,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -48,7 +47,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -61,7 +59,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php index c32ef7f10e3..84fd1c59dc3 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php @@ -36,7 +36,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -49,7 +48,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -62,7 +60,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php index bde9382cef4..ce4efddafd7 100644 --- a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php @@ -30,7 +30,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -43,7 +42,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -56,7 +54,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php index 766f6d1aa77..8f706ac679f 100644 --- a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php @@ -36,7 +36,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -49,7 +48,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -62,7 +60,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index 7ac1a41eaee..8d856110da9 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -32,7 +32,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -45,7 +44,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -58,7 +56,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index cb51bb02687..7688ec38568 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -29,7 +29,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php index b706d1569f8..9e7ed77f674 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php @@ -16,7 +16,6 @@ public function getRule(): Rule return new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), true, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php index f2b07c46b6e..5252c264e67 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php @@ -16,7 +16,6 @@ public function getRule(): Rule return new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), true, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index 27c4bbac60a..4a11f703d65 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -25,7 +25,6 @@ public function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php index 1a479ec9784..27fa1390e57 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php @@ -25,7 +25,6 @@ public function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php index a64a549b5ba..834386423f5 100644 --- a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php @@ -31,7 +31,6 @@ protected function getRule(): TRule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -44,7 +43,6 @@ protected function getRule(): TRule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -57,7 +55,6 @@ protected function getRule(): TRule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index 063e1d531f0..f0e54e635cf 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -31,7 +31,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -44,7 +43,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -57,7 +55,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index dce53141a33..ba69d01a77f 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -32,7 +32,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -45,7 +44,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), @@ -58,7 +56,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php index e05b9254a39..0ed691a60b4 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php @@ -30,7 +30,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -43,7 +42,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -56,7 +54,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php index 4dc0cfba24e..23846895d88 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php @@ -30,7 +30,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -43,7 +42,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), @@ -56,7 +54,6 @@ protected function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), From 7170e829505bc5e264cd9aa512d27fedb454ad22 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:35 +0200 Subject: [PATCH 11/32] Inline nullsafe short-circuiting and compose receiver narrowing NullsafeShortCircuitingHelper's recursive chain walk is gone: expressions process inside-out, so only the nullsafe handlers ever see a ?-> link, and the other fetch and call handlers short-circuit through the operand result's containsNullsafe flag. The nullsafe handlers walk the receiver exactly once, consume the stored result for the plain twin, and compose the narrowing as receiver !== null && chain-truthy through the boolean helper, fanned through impure gates and default narrowing. NonNullabilityHelper keeps an explicit ensure stack so the handlers can recover the pre-device nullable receiver type, and resets it per file: an internal error escaping between an ensure and its revert must not leak a stale frame into the next file of the worker's batch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- .../Helper/NonNullabilityHelper.php | 109 ++++++++-- .../Helper/NullsafeShortCircuitingHelper.php | 46 ----- .../ExprHandler/NullsafeMethodCallHandler.php | 190 +++++++++++++----- .../NullsafePropertyFetchHandler.php | 177 +++++++++++----- .../Helper/NonNullabilityHelperTest.php | 35 ++++ .../nsrt/nullsafe-impure-call-narrowing.php | 78 +++++++ 6 files changed, 476 insertions(+), 159 deletions(-) delete mode 100644 src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php create mode 100644 tests/PHPStan/Analyser/ExprHandler/Helper/NonNullabilityHelperTest.php create mode 100644 tests/PHPStan/Analyser/nsrt/nullsafe-impure-call-narrowing.php diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 647036c7c80..afdb31989ec 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -12,18 +12,91 @@ use PHPStan\Analyser\EnsuredNonNullabilityResult; use PHPStan\Analyser\EnsuredNonNullabilityResultExpression; use PHPStan\Analyser\MutatingScope; -use PHPStan\Analyser\Scope; +use PHPStan\Analyser\PerFileAnalysisResettable; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\Printer\ExprPrinter; use PHPStan\TrinaryLogic; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_pop; +use function count; #[AutowiredService] -final class NonNullabilityHelper +final class NonNullabilityHelper implements PerFileAnalysisResettable { - public function ensureShallowNonNullability(MutatingScope $scope, Scope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + /** + * The ensures currently in effect during the walk, innermost last. An + * ensure writes non-null "device" types into the scope so nested fetches + * walk without spurious possibly-null noise - indistinguishable from + * genuine narrowing in scope state. Handlers whose semantics depend on an + * expression's REAL nullability (a nullsafe operator's short-circuit) + * consult this stack for the pre-device type instead. + * + * @var list> + */ + private array $activeEnsures = []; + + public function __construct(private ExprPrinter $exprPrinter) + { + } + + /** + * An internal error escaping between an ensure and its revert would leave + * stale frames matched by common print keys ($this->foo) for the rest of + * the worker's batch - the per-file reset clears them. + */ + public function resetFileAnalysisState(): void + { + $this->activeEnsures = []; + } + + /** + * The pre-device type an active ensure saved for this expression, or null + * when no ensure covers it. + */ + public function getActiveEnsuredOriginalType(Expr $expr, bool $native): ?Type + { + if ($this->activeEnsures === []) { + return null; + } + + $key = $this->exprPrinter->printExpr($expr); + for ($i = count($this->activeEnsures) - 1; $i >= 0; $i--) { + if (isset($this->activeEnsures[$i][$key])) { + return $this->activeEnsures[$i][$key][$native ? 1 : 0]; + } + } + + return null; + } + + public function ensureShallowNonNullability(MutatingScope $scope, MutatingScope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult { - $exprType = $scope->getType($exprToSpecify); + $result = $this->doEnsureShallowNonNullability($scope, $originalScope, $exprToSpecify); + $this->pushActiveEnsure($result); + + return $result; + } + + private function pushActiveEnsure(EnsuredNonNullabilityResult $result): void + { + $originals = []; + foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { + $originals[$this->exprPrinter->printExpr($specifiedExpression->getExpression())] = [ + $specifiedExpression->getOriginalType(), + $specifiedExpression->getOriginalNativeType(), + ]; + } + $this->activeEnsures[] = $originals; + } + + private function doEnsureShallowNonNullability(MutatingScope $scope, MutatingScope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + { + // the expression has not been processed into the storage yet (this runs + // before processExprNode) - derive its current type from the scope's + // tracked state instead of pricing the node on demand. + $exprType = $scope->getStateType($exprToSpecify); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -33,9 +106,9 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = $originalScope->getType($exprToSpecify); + $originalExprType = $originalScope->getStateType($exprToSpecify); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = $originalScope->getNativeType($exprToSpecify); + $originalNativeType = $originalScope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -53,8 +126,8 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - $scope->getType($parentExpr), - $scope->getNativeType($parentExpr), + $scope->getStateType($parentExpr), + $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($parentExpr), $originalScope->hasExpressionType($parentExpr), ); } @@ -68,7 +141,7 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $certainty = $hasExpressionType; } - $nativeType = $scope->getNativeType($exprToSpecify); + $nativeType = $scope->doNotTreatPhpDocTypesAsCertain()->getStateType($exprToSpecify); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, @@ -88,14 +161,17 @@ public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredN $specifiedExpressions = []; $originalScope = $scope; $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope) { - $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr); + $result = $this->doEnsureShallowNonNullability($scope, $originalScope, $expr); foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { $specifiedExpressions[] = $specifiedExpression; } return $result->getScope(); - }); + }, false); + + $result = new EnsuredNonNullabilityResult($scope, $specifiedExpressions); + $this->pushActiveEnsure($result); - return new EnsuredNonNullabilityResult($scope, $specifiedExpressions); + return $result; } /** @@ -103,6 +179,7 @@ public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredN */ public function revertNonNullability(MutatingScope $scope, array $specifiedExpressions): MutatingScope { + array_pop($this->activeEnsures); foreach ($specifiedExpressions as $specifiedExpressionResult) { if ($specifiedExpressionResult->getCertainty()->no()) { $scope = $scope->invalidateExpression($specifiedExpressionResult->getExpression()); @@ -122,9 +199,13 @@ public function revertNonNullability(MutatingScope $scope, array $specifiedExpre /** * @param Closure(MutatingScope, Expr): MutatingScope $callback */ - private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Closure $callback): MutatingScope + private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Closure $callback, bool $includeExpr = true): MutatingScope { - if (!$expr instanceof ArrayDimFetch || $expr->dim !== null) { + // $includeExpr is false only for the outermost operand: ensuring its chain + // links non-null lets it be walked without spurious "possibly null" noise, + // but the operand's own value must keep its real (nullable) type - that is + // the type the isset/empty/?? verdict and narrowing read from its result. + if ($includeExpr && (!$expr instanceof ArrayDimFetch || $expr->dim !== null)) { $scope = $callback($scope, $expr); } diff --git a/src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php b/src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php deleted file mode 100644 index 9c0c48d94d2..00000000000 --- a/src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php +++ /dev/null @@ -1,46 +0,0 @@ -getType($expr->var); - if (TypeCombinator::containsNull($varType)) { - return TypeCombinator::addNull($type); - } - - return $type; - } - - if ($expr instanceof ArrayDimFetch || $expr instanceof PropertyFetch || $expr instanceof MethodCall) { - $expr = $expr->var; - continue; - } - - if (($expr instanceof StaticPropertyFetch || $expr instanceof StaticCall) && $expr->class instanceof Expr) { - $expr = $expr->class; - continue; - } - - return $type; - } - } - -} diff --git a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 30ddfc2c4c2..23425fdbada 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\BooleanAnd; use PhpParser\Node\Expr\BinaryOp\NotIdentical; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\MethodCall; @@ -15,12 +14,12 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\NullsafeMethodCallExpressionNode; @@ -40,6 +39,8 @@ final class NullsafeMethodCallHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -49,61 +50,43 @@ public function supports(Expr $expr): bool return $expr instanceof NullsafeMethodCall; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->var); - if ($varType->isNull()->yes()) { - return new NullType(); - } - if (!TypeCombinator::containsNull($varType)) { - return $scope->getType(new MethodCall($expr->var, $expr->name, $expr->args)); - } - - return TypeCombinator::union( - $scope->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))) - ->getType(new MethodCall($expr->var, $expr->name, $expr->args)), - new NullType(), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - $types = $typeSpecifier->specifyTypesInCondition( - $scope, - new BooleanAnd( - new NotIdentical($expr->var, new ConstFetch(new Name('null'))), - new MethodCall($expr->var, $expr->name, $expr->args), - ), - $context, - )->setRootExpr($expr); - - $nullSafeTypes = $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->intersectWith($nullSafeTypes); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - $calledOnType = $scope->getScopeType($expr->var); - $calledOnNativeType = $scope->getScopeNativeType($expr->var); $scopeBeforeNullsafe = $scope; - $varType = $scope->getType($expr->var); + // the receiver is processed ONCE here, on the pre-ensure scope; the + // plain-twin walk below CONSUMES its stored result instead of re-walking + // it. Its result carries the receiver's real (possibly null) type - the + // short-circuit decision needs to know it can be null, which reading the + // ensured-non-null state would hide. An enclosing isset/empty/?? ensure + // may have deviced the receiver in scope state, so the ensure stack's + // original type still wins. + $processedReceiverResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $processedReceiverResult->getScope(); + $receiverType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, false) ?? $processedReceiverResult->getType(); + $receiverNativeType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, true) ?? $processedReceiverResult->getNativeType(); + // carry the receiver type to NullsafeMethodCallRule so it reads it from here + // instead of asking the scope for the unprocessed receiver. + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafeMethodCallExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + // pre-store the receiver's ensured-position view: rules suspended at the + // plain twin's callback ask about the receiver BEFORE the twin walk + // consumes it, and must see the same (deviced non-null) answer the twin + // walk itself will consume - exactly what storing the receiver walked + // inside the twin used to produce + $nodeScopeResolver->storeExpressionResult($storage, $expr->var, $processedReceiverResult->atAskPosition($nonNullabilityResult->getScope())); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); - $exprResult = $nodeScopeResolver->processExprNode( + $methodCall = new MethodCall( + $expr->var, + $expr->name, + $expr->args, + $attributes, + ); + $exprResult = $nodeScopeResolver->processExprNodeConsumingStored( $stmt, - new MethodCall( - $expr->var, - $expr->name, - $expr->args, - $attributes, - ), + $methodCall, $nonNullabilityResult->getScope(), $storage, $nodeCallback, @@ -111,7 +94,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); - $varIsNull = $varType->isNull(); + $varIsNull = $receiverType->isNull(); if ($varIsNull->yes()) { // Arguments are never evaluated when the var is always null. $scope = $scopeBeforeNullsafe; @@ -121,9 +104,32 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } - // the nullsafe operation is processed; emit a virtual node carrying the - // receiver's entry-scope type so its rule does not re-ask the scope - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafeMethodCallExpressionNode($expr, $calledOnType, $calledOnNativeType), $beforeScope, $storage, $context); + // The `?->`'s own type on the asking scope. $receiverType is the receiver's + // real type, captured before it was ensured non-null; reading its stored + // result here would see the non-null device type and drop the + // short-circuit's null. + $nullsafeTypeCallback = static function (bool $nativeTypesPromoted) use ($exprResult, $receiverType): Type { + if ($receiverType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($receiverType)) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } + + // the plain call was already priced on the ensured (null-removed) + // scope during processExpr - its result is the call's type on the + // non-null receiver; the short-circuit contributes the null + return TypeCombinator::union( + $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(), + new NullType(), + ); + }; + + // the receiver's stored result, for composing the receiver-not-null + // narrowing without re-walking the chain + $receiverResult = $processedReceiverResult; + // lazily memoized receiver-is-null branch scope of the decomposition + $leftFalseyScope = null; return $this->expressionResultFactory->create( $scope, @@ -134,6 +140,84 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, + typeCallback: $nullsafeTypeCallback, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $methodCall, $exprResult, $receiverResult, $nonNullabilityResult, $beforeScope, $nodeScopeResolver, &$leftFalseyScope): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + // `$x?->...` narrows like ($x !== null) && $x->..., composed from + // the captured receiver and plain-twin results - the fabricated + // NotIdentical is only printed into holder keys, never walked + $notIdenticalNode = new NotIdentical($expr->var, new ConstFetch(new Name('null'))); + $leftTypes = function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($expr, $receiverResult, $notIdenticalNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notIdenticalNode, $ctx); + } + + return $this->defaultNarrowingHelper->createSubjectTypes($scope, $expr->var, $receiverResult, new NullType(), $ctx->negate()); + }; + $rightTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($scope, $ctx); + + $types = $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $notIdenticalNode, + $leftTypes, + static fn (): MutatingScope => $nonNullabilityResult->getScope(), + // the plain twin was walked on the ensured-non-null scope - that + // is the left-truthy evaluation point; the receiver-is-null + // branch scope has no walk analog and derives on first demand + static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingScope { + return $leftFalseyScope ??= $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); + }, + $methodCall, + $rightTypes, + static fn (): MutatingScope => $exprResult->getFalseyScope(), + )->setRootExpr($expr); + + $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->intersectWith($nullSafeTypes); + }, + // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. + // The short-circuit's null surfaces here, never by walking the chain: + // a receiver that is itself a ?-> composes through the parent handler. + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $methodCall, $exprResult, $receiverResult, $nullsafeTypeCallback, $beforeScope): SpecifiedTypes { + // null() context: createForExpr never computes $containsNull and + // emits no entry for the subject - behave the same. + if ($context->null()) { + return (new SpecifiedTypes())->setRootExpr($expr); + } + + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $nullsafeType = $nullsafeTypeCallback($nativeTypesPromoted); + if ($context->true()) { + $containsNull = !$type->isNull()->no() && !$nullsafeType->isNull()->no(); + } else { + $containsNull = !TypeCombinator::containsNull($type) && !$nullsafeType->isNull()->no(); + } + + // The ?-> may legitimately be null (e.g. narrowed to a nullable + // $type): keep the ?-> node's own key only, no plain chain, no + // receiver-not-null - exactly createForExpr's containsNull branch. + if ($containsNull) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->setRootExpr($expr); + } + + // !containsNull: the plain inner methodCall narrowed by $type + // (createNullsafeTypes), the original ?-> key (createForExpr's + // double-key), and "receiver is not null". + // the receiver composes through its own result so a nullsafe + // receiver fans "not null" down its whole chain + return $this->defaultNarrowingHelper->createSubjectTypes($s, $methodCall, $exprResult, $type, $context) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, $receiverResult, new NullType(), TypeSpecifierContext::createFalse())) + ->setRootExpr($expr); + }, ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index a81a392a55d..fe153e82369 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\BooleanAnd; use PhpParser\Node\Expr\BinaryOp\NotIdentical; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\NullsafePropertyFetch; @@ -15,12 +14,12 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\NullsafePropertyFetchExpressionNode; @@ -40,6 +39,8 @@ final class NullsafePropertyFetchHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -49,60 +50,66 @@ public function supports(Expr $expr): bool return $expr instanceof NullsafePropertyFetch; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->var); - if ($varType->isNull()->yes()) { - return new NullType(); - } - if (!TypeCombinator::containsNull($varType)) { - return $scope->getType(new PropertyFetch($expr->var, $expr->name)); - } - - return TypeCombinator::union( - $scope->filterByTruthyValue(new NotIdentical($expr->var, new ConstFetch(new Name('null')))) - ->getType(new PropertyFetch($expr->var, $expr->name)), - new NullType(), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - $types = $typeSpecifier->specifyTypesInCondition( - $scope, - new BooleanAnd( - new NotIdentical($expr->var, new ConstFetch(new Name('null'))), - new PropertyFetch($expr->var, $expr->name), - ), - $context, - )->setRootExpr($expr); - - $nullSafeTypes = $typeSpecifier->handleDefaultTruthyOrFalseyContext($context, $expr, $scope); - return $context->true() ? $types->unionWith($nullSafeTypes) : $types->intersectWith($nullSafeTypes); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; - $calledOnType = $scope->getScopeType($expr->var); - $calledOnNativeType = $scope->getScopeNativeType($expr->var); + // the receiver is processed ONCE here, on the pre-ensure scope; the + // plain-twin walk below CONSUMES its stored result instead of re-walking + // it. Its result carries the receiver's real (possibly null) type - the + // short-circuit decision needs to know it can be null, which reading the + // ensured-non-null state would hide. An enclosing isset/empty/?? ensure + // may have deviced the receiver in scope state, so the ensure stack's + // original type still wins. + $processedReceiverResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $scope = $processedReceiverResult->getScope(); + $receiverType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, false) ?? $processedReceiverResult->getType(); + $receiverNativeType = $this->nonNullabilityHelper->getActiveEnsuredOriginalType($expr->var, true) ?? $processedReceiverResult->getNativeType(); + // carry the receiver type to NullsafePropertyFetchRule so it reads it from + // here instead of asking the scope for the unprocessed receiver. + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafePropertyFetchExpressionNode($expr, $receiverType, $receiverNativeType), $beforeScope, $storage, $context); $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + // pre-store the receiver's ensured-position view: rules suspended at the + // plain twin's callback ask about the receiver BEFORE the twin walk + // consumes it, and must see the same (deviced non-null) answer the twin + // walk itself will consume - exactly what storing the receiver walked + // inside the twin used to produce + $nodeScopeResolver->storeExpressionResult($storage, $expr->var, $processedReceiverResult->atAskPosition($nonNullabilityResult->getScope())); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafePropertyFetch' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); - $exprResult = $nodeScopeResolver->processExprNode($stmt, new PropertyFetch( + $propertyFetch = new PropertyFetch( $expr->var, $expr->name, $attributes, - ), $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); + ); + $exprResult = $nodeScopeResolver->processExprNodeConsumingStored($stmt, $propertyFetch, $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); - // the nullsafe operation is processed; emit a virtual node carrying the - // receiver's entry-scope type so its rule does not re-ask the scope - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new NullsafePropertyFetchExpressionNode($expr, $calledOnType, $calledOnNativeType), $beforeScope, $storage, $context); + // The `?->`'s own type on the asking scope. $receiverType is the receiver's + // real type, captured before it was ensured non-null; reading its stored + // result here would see the non-null device type and drop the + // short-circuit's null. + $nullsafeTypeCallback = static function (bool $nativeTypesPromoted) use ($exprResult, $receiverType): Type { + if ($receiverType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($receiverType)) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } + + // the plain fetch was already priced on the ensured (null-removed) + // scope during processExpr - its result is the fetch's type on the + // non-null receiver; the short-circuit contributes the null + return TypeCombinator::union( + $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(), + new NullType(), + ); + }; + + // the receiver's stored result, for composing the receiver-not-null + // narrowing without re-walking the chain + $receiverResult = $processedReceiverResult; + // lazily memoized receiver-is-null branch scope of the decomposition + $leftFalseyScope = null; return $this->expressionResultFactory->create( $scope, @@ -113,6 +120,84 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), containsNullsafe: true, + typeCallback: $nullsafeTypeCallback, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $propertyFetch, $exprResult, $receiverResult, $nonNullabilityResult, $beforeScope, $nodeScopeResolver, &$leftFalseyScope): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + // `$x?->...` narrows like ($x !== null) && $x->..., composed from + // the captured receiver and plain-twin results - the fabricated + // NotIdentical is only printed into holder keys, never walked + $notIdenticalNode = new NotIdentical($expr->var, new ConstFetch(new Name('null'))); + $leftTypes = function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($expr, $receiverResult, $notIdenticalNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notIdenticalNode, $ctx); + } + + return $this->defaultNarrowingHelper->createSubjectTypes($scope, $expr->var, $receiverResult, new NullType(), $ctx->negate()); + }; + $rightTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($scope, $ctx); + + $types = $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $notIdenticalNode, + $leftTypes, + static fn (): MutatingScope => $nonNullabilityResult->getScope(), + // the plain twin was walked on the ensured-non-null scope - that + // is the left-truthy evaluation point; the receiver-is-null + // branch scope has no walk analog and derives on first demand + static function () use ($beforeScope, $leftTypes, &$leftFalseyScope): MutatingScope { + return $leftFalseyScope ??= $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); + }, + $propertyFetch, + $rightTypes, + static fn (): MutatingScope => $exprResult->getFalseyScope(), + )->setRootExpr($expr); + + $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->intersectWith($nullSafeTypes); + }, + // Inside-out copy of TypeSpecifier::createForExpr()'s `?->` handling. + // The short-circuit's null surfaces here, never by walking the chain: + // a receiver that is itself a ?-> composes through the parent handler. + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $propertyFetch, $exprResult, $receiverResult, $nullsafeTypeCallback, $beforeScope): SpecifiedTypes { + // null() context: createForExpr never computes $containsNull and + // emits no entry for the subject - behave the same. + if ($context->null()) { + return (new SpecifiedTypes())->setRootExpr($expr); + } + + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $nullsafeType = $nullsafeTypeCallback($nativeTypesPromoted); + if ($context->true()) { + $containsNull = !$type->isNull()->no() && !$nullsafeType->isNull()->no(); + } else { + $containsNull = !TypeCombinator::containsNull($type) && !$nullsafeType->isNull()->no(); + } + + // The ?-> may legitimately be null (e.g. narrowed to a nullable + // $type): keep the ?-> node's own key only, no plain chain, no + // receiver-not-null - exactly createForExpr's containsNull branch. + if ($containsNull) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->setRootExpr($expr); + } + + // !containsNull: the plain inner propertyFetch narrowed by $type + // (createNullsafeTypes), the original ?-> key (createForExpr's + // double-key), and "receiver is not null". + // the receiver composes through its own result so a nullsafe + // receiver fans "not null" down its whole chain + return $this->defaultNarrowingHelper->createSubjectTypes($s, $propertyFetch, $exprResult, $type, $context) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, $receiverResult, new NullType(), TypeSpecifierContext::createFalse())) + ->setRootExpr($expr); + }, ); } diff --git a/tests/PHPStan/Analyser/ExprHandler/Helper/NonNullabilityHelperTest.php b/tests/PHPStan/Analyser/ExprHandler/Helper/NonNullabilityHelperTest.php new file mode 100644 index 00000000000..dc2e5fd98e8 --- /dev/null +++ b/tests/PHPStan/Analyser/ExprHandler/Helper/NonNullabilityHelperTest.php @@ -0,0 +1,35 @@ +getByType(NonNullabilityHelper::class); + + $reflectionProvider = self::createReflectionProvider(); + $scopeFactory = self::createScopeFactory($reflectionProvider, self::getContainer()->getService('typeSpecifier')); + $nullableInt = TypeCombinator::addNull(new IntegerType()); + $scope = $scopeFactory->create(ScopeContext::create('file.php')) + ->assignVariable('a', $nullableInt, $nullableInt, TrinaryLogic::createYes()); + $expr = new Variable('a'); + + $helper->ensureShallowNonNullability($scope, $scope, $expr); + $this->assertNotNull($helper->getActiveEnsuredOriginalType($expr, false)); + + // an internal error escaping between ensure and revert must not leak the + // stale frame into the next file's analysis + $helper->resetFileAnalysisState(); + $this->assertNull($helper->getActiveEnsuredOriginalType($expr, false)); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/nullsafe-impure-call-narrowing.php b/tests/PHPStan/Analyser/nsrt/nullsafe-impure-call-narrowing.php new file mode 100644 index 00000000000..391a6826197 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/nullsafe-impure-call-narrowing.php @@ -0,0 +1,78 @@ +dep = new Dependency(); + } + +} + +function notNullComparison(?Holder $a): void +{ + if ($a?->dep->impure() !== null) { + assertType('NullsafeImpureCallNarrowing\Holder', $a); + } +} + +function truthyContext(?Holder $a): void +{ + if ($a?->dep->impure()) { + assertType('NullsafeImpureCallNarrowing\Holder', $a); + } +} + +class DataDep +{ + + public ?string $label = null; + + /** @var array */ + public array $items = []; + +} + +class DataHolder +{ + + public DataDep $dep; + + public function __construct() + { + $this->dep = new DataDep(); + } + +} + +function truthyPropertyFetch(?DataHolder $a): void +{ + if ($a?->dep->label) { + assertType('NullsafeImpureCallNarrowing\DataHolder', $a); + } +} + +function truthyDimFetch(?DataHolder $a): void +{ + if ($a?->dep->items[0]) { + assertType('NullsafeImpureCallNarrowing\DataHolder', $a); + } +} From 9ed573b905d2d96a04d5890a4cce2338f8f03958 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:35 +0200 Subject: [PATCH 12/32] Fetch handlers read operand results PropertyFetch, StaticPropertyFetch, ArrayDimFetch and Variable move their type resolution into result callbacks over the walked child results. ArrayDimFetch resolves offsetGet through MethodCallReturnTypeHelper per flavour on a fabricated, never-walked MethodCall; dynamic $$name resolution composes name === '...' through IdenticalNarrowingHelper instead of filtering by a synthetic Identical walk. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- .../ExprHandler/ArrayDimFetchHandler.php | 84 ++++++------ .../ExprHandler/PropertyFetchHandler.php | 106 ++++++++------- .../StaticPropertyFetchHandler.php | 127 +++++++++--------- src/Analyser/ExprHandler/VariableHandler.php | 121 +++++++++++------ 4 files changed, 243 insertions(+), 195 deletions(-) diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index dfdc46932d3..bd0172f3804 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -14,20 +14,22 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Type\ErrorType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_merge; /** @@ -39,7 +41,9 @@ final class ArrayDimFetchHandler implements ExprHandler public function __construct( private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, private MethodThrowPointHelper $methodThrowPointHelper, + private MethodCallReturnTypeHelper $methodCallReturnTypeHelper, ) { } @@ -49,40 +53,6 @@ public function supports(Expr $expr): bool return $expr instanceof ArrayDimFetch; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->dim === null) { - return new NeverType(); - } - - $offsetAccessibleType = $scope->getType($expr->var); - if ( - !$offsetAccessibleType->isArray()->yes() - && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() - ) { - return NullsafeShortCircuitingHelper::getType( - $scope, - $expr->var, - $scope->getType( - new MethodCall( - $expr->var, - new Identifier('offsetGet'), - [ - new Arg($expr->dim), - ], - ), - ), - ); - } - - $offsetType = $scope->getType($expr->dim); - return NullsafeShortCircuitingHelper::getType( - $scope, - $expr->var, - $offsetAccessibleType->getOffsetValueType($offsetType), - ); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -118,6 +88,9 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), containsNullsafe: $varResult->containsNullsafe(), + // `$arr[]` only appears as an assignment target; reading it is a NeverType + typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } @@ -125,6 +98,7 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, $impurePoints = array_merge($dimResult->getImpurePoints(), $varResult->getImpurePoints()); $varType = $varResult->getType(); + $offsetGetCall = null; if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType( $scope, @@ -132,6 +106,11 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, $varType, new MethodCall(new TypeExpr($varType), 'offsetGet'), )); + // the offsetGet return type resolves directly in the typeCallback (per + // flavour); the fabricated node is only the payload dynamic return + // type extensions receive - nothing walks it. Gated by the same + // maybe-ArrayAccess condition, so plain arrays never reach it. + $offsetGetCall = new MethodCall($expr->var, new Identifier('offsetGet'), [new Arg($expr->dim)]); } return $this->expressionResultFactory->create( @@ -144,12 +123,33 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::offset($varResult, $dimResult), + typeCallback: function (bool $nativeTypesPromoted) use ($varResult, $dimResult, $offsetGetCall, $scope): Type { + $offsetAccessibleType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($offsetAccessibleType) + ? TypeCombinator::addNull($type) + : $type; + + if ( + $offsetGetCall !== null + && !$offsetAccessibleType->isArray()->yes() + && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() + ) { + if ($nativeTypesPromoted) { + $methodReflection = $scope->getMethodReflection($offsetAccessibleType, 'offsetGet'); + if ($methodReflection === null) { + return $shortCircuit(new ErrorType()); + } + + return $shortCircuit(ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants())->getNativeReturnType()); + } + + return $shortCircuit($this->methodCallReturnTypeHelper->methodCallReturnType($scope, $offsetAccessibleType, 'offsetGet', $offsetGetCall) ?? new ErrorType()); + } + + return $shortCircuit($offsetAccessibleType->getOffsetValueType(($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()))); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypesWithNullsafeFan($expr, $context, $beforeScope, $nativeTypesPromoted), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index a5a97c2c21c..52acd95c08e 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -5,26 +5,24 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Identifier; -use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use PHPStan\Rules\Properties\FoundPropertyReflection; use PHPStan\Rules\Properties\PropertyReflectionFinder; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -44,6 +42,7 @@ public function __construct( private PhpVersion $phpVersion, private PropertyReflectionFinder $propertyReflectionFinder, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -113,52 +112,64 @@ public function composeResult(NodeScopeResolver $nodeScopeResolver, PropertyFetc impurePoints: $impurePoints, containsNullsafe: $varResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::property($varResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), - ); - } + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $varResult, $nameResult, $beforeScope): Type { + // a fetch on a nullsafe chain whose receiver is currently nullable + // short-circuits to null - the receiver result carries whether the + // chain contains a ?-> (a plain nullable receiver does not propagate) + $receiverType = $nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType(); + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($receiverType) + ? TypeCombinator::addNull($type) + : $type; + + // the property's class/visibility/assign context is lexical, so it + // comes from beforeScope; the scope-dependent receiver type is read + // from the operand result above. + $reflectionScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $resolveProperty = function (string $propertyName) use ($nativeTypesPromoted, $reflectionScope, $receiverType, $expr): Type { + if ($nativeTypesPromoted) { + $propertyReflection = $reflectionScope->getInstancePropertyReflection($receiverType, $propertyName); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } + + return $propertyReflection->getNativeType(); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->name instanceof Identifier) { - if ($scope->nativeTypesPromoted) { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $scope); - if ($propertyReflection === null) { - return new ErrorType(); - } + return $this->propertyFetchType($reflectionScope, $receiverType, $propertyName, $expr) ?? new ErrorType(); + }; - if (!$propertyReflection->hasNativeType()) { - return new MixedType(); + if ($expr->name instanceof Identifier) { + return $shortCircuit($resolveProperty($expr->name->toString())); } - $nativeType = $propertyReflection->getNativeType(); - - return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $nativeType); - } - - $returnType = $this->propertyFetchType( - $scope, - $scope->getType($expr->var), - $expr->name->name, - $expr, - ); - if ($returnType === null) { - $returnType = new ErrorType(); - } - - return NullsafeShortCircuitingHelper::getType($scope, $expr->var, $returnType); - } - - $nameType = $scope->getType($expr->name); - if (count($nameType->getConstantStrings()) > 0) { - return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $scope - ->filterByTruthyValue(new Expr\BinaryOp\Identical($expr->name, new String_($constantString->getValue()))) - ->getType( - new PropertyFetch($expr->var, new Identifier($constantString->getValue())), - ), $nameType->getConstantStrings()), - ); - } + // dynamic property fetch $obj->$name: resolve each possible name + // from beforeScope. The asking scope is not narrowed per name, so + // $obj->{'foo'}-style fetches can be less precise. Every caller + // walks a non-Identifier name and passes its result. + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + $nameType = $nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType(); + if (count($nameType->getConstantStrings()) > 0) { + return TypeCombinator::union( + ...array_map(static function ($constantString) use ($resolveProperty): Type { + if ($constantString->getValue() === '') { + return new ErrorType(); + } + + return $resolveProperty($constantString->getValue()); + }, $nameType->getConstantStrings()), + ); + } - return new MixedType(); + return new MixedType(); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypesWithNullsafeFan($expr, $context, $beforeScope, $nativeTypesPromoted), + ); } private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, PropertyFetch $propertyFetch): ?Type @@ -175,9 +186,4 @@ private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, st return $propertyReflection->getReadableType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index 3283f6fdadf..bf2311d0de6 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -3,10 +3,8 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\StaticPropertyFetch; use PhpParser\Node\Name; -use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PhpParser\Node\VarLikeIdentifier; use PHPStan\Analyser\ExpressionContext; @@ -14,18 +12,17 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; -use PHPStan\Analyser\ExprHandler\Helper\NullsafeShortCircuitingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Rules\Properties\FoundPropertyReflection; use PHPStan\Rules\Properties\PropertyReflectionFinder; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -44,6 +41,7 @@ final class StaticPropertyFetchHandler implements ExprHandler public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -91,14 +89,12 @@ public function composeResult(StaticPropertyFetch $expr, ?ExpressionResult $clas ), ]; $isAlwaysTerminating = false; - $containsNullsafe = false; if ($classResult !== null) { $hasYield = $classResult->hasYield(); $throwPoints = $classResult->getThrowPoints(); $impurePoints = $classResult->getImpurePoints(); $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $scope = $classResult->getScope(); - $containsNullsafe = $classResult->containsNullsafe(); } if ($nameResult !== null) { $hasYield = $hasYield || $nameResult->hasYield(); @@ -116,65 +112,75 @@ public function composeResult(StaticPropertyFetch $expr, ?ExpressionResult $clas isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - containsNullsafe: $containsNullsafe, + containsNullsafe: $classResult !== null && $classResult->containsNullsafe(), issetabilityDescriptor: IssetabilityDescriptor::property($classResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->name instanceof VarLikeIdentifier) { - if ($scope->nativeTypesPromoted) { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $scope); - if ($propertyReflection === null) { - return new ErrorType(); - } - if (!$propertyReflection->hasNativeType()) { - return new MixedType(); + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $classResult, $nameResult, $beforeScope): Type { + $classType = $classResult !== null + ? ($nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) + : null; + $shortCircuit = static fn (Type $type): Type => $classResult !== null && $classResult->containsNullsafe() && $classType !== null && TypeCombinator::containsNull($classType) + ? TypeCombinator::addNull($type) + : $type; + + // the property's class/visibility/assign context is lexical, so it + // comes from beforeScope; the scope-dependent class-expression type + // is read from the operand result above, and the native-vs-phpdoc + // distinction comes from that type and the reflection accessor below. + $reflectionScope = $beforeScope; + if ($expr->class instanceof Name) { + $staticPropertyFetchedOnType = $reflectionScope->resolveTypeByName($expr->class); + } else { + // every caller walks a non-Name class and passes its result + if ($classType === null) { + throw new ShouldNotHappenException(); + } + $staticPropertyFetchedOnType = TypeCombinator::removeNull($classType)->getObjectTypeOrClassStringObjectType(); } - $nativeType = $propertyReflection->getNativeType(); + $resolveProperty = function (string $propertyName) use ($nativeTypesPromoted, $reflectionScope, $staticPropertyFetchedOnType, $expr): Type { + if ($nativeTypesPromoted) { + $propertyReflection = $reflectionScope->getStaticPropertyReflection($staticPropertyFetchedOnType, $propertyName); + if ($propertyReflection === null) { + return new ErrorType(); + } + if (!$propertyReflection->hasNativeType()) { + return new MixedType(); + } - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $nativeType); - } - - return $nativeType; - } - - if ($expr->class instanceof Name) { - $staticPropertyFetchedOnType = $scope->resolveTypeByName($expr->class); - } else { - $staticPropertyFetchedOnType = TypeCombinator::removeNull($scope->getType($expr->class))->getObjectTypeOrClassStringObjectType(); - } - - $fetchType = $this->propertyFetchType( - $scope, - $staticPropertyFetchedOnType, - $expr->name->toString(), - $expr, - ); - if ($fetchType === null) { - $fetchType = new ErrorType(); - } + return $propertyReflection->getNativeType(); + } - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $fetchType); - } + return $this->propertyFetchType($reflectionScope, $staticPropertyFetchedOnType, $propertyName, $expr) ?? new ErrorType(); + }; - return $fetchType; - } + if ($expr->name instanceof VarLikeIdentifier) { + return $shortCircuit($resolveProperty($expr->name->toString())); + } - $nameType = $scope->getType($expr->name); - if (count($nameType->getConstantStrings()) > 0) { - return TypeCombinator::union( - ...array_map(static fn ($constantString) => $constantString->getValue() === '' ? new ErrorType() : $scope - ->filterByTruthyValue(new Identical($expr->name, new String_($constantString->getValue()))) - ->getType(new Expr\StaticPropertyFetch($expr->class, new VarLikeIdentifier($constantString->getValue()))), $nameType->getConstantStrings()), - ); - } + // dynamic static property fetch Foo::${$name}: resolve each possible + // name from beforeScope. The asking scope is not narrowed per name, + // so such fetches can be less precise. + // every caller walks a non-VarLikeIdentifier name and passes its result + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + $nameType = $nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType(); + if (count($nameType->getConstantStrings()) > 0) { + return TypeCombinator::union( + ...array_map(static function ($constantString) use ($resolveProperty): Type { + if ($constantString->getValue() === '') { + return new ErrorType(); + } + + return $resolveProperty($constantString->getValue()); + }, $nameType->getConstantStrings()), + ); + } - return new MixedType(); + return new MixedType(); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, StaticPropertyFetch $propertyFetch): ?Type @@ -191,9 +197,4 @@ private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, st return $propertyReflection->getReadableType(); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 8cdfdd7b387..33352dad581 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -2,8 +2,8 @@ namespace PHPStan\Analyser\ExprHandler; +use Closure; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\Variable; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; @@ -12,15 +12,18 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\IssetabilityDescriptor; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -36,7 +39,12 @@ final class VariableHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) { } @@ -45,36 +53,73 @@ public function supports(Expr $expr): bool return $expr instanceof Variable; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * Evaluates the variable as a read on the asking scope. + * + * @return Closure(bool $nativeTypesPromoted): Type + */ + private function createTypeCallback(Variable $expr, NodeScopeResolver $nodeScopeResolver, MutatingScope $beforeScope, ?ExpressionResult $nameResult = null, ?ExpressionResult $nameArgResult = null): Closure { - if (is_string($expr->name)) { - if ($scope->hasVariableType($expr->name)->no()) { - return new ErrorType(); - } + return function (bool $nativeTypesPromoted) use ($expr, $nameResult, $nameArgResult, $nodeScopeResolver, $beforeScope): Type { + $readScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if (is_string($expr->name)) { + if ($readScope->hasVariableType($expr->name)->no()) { + return new ErrorType(); + } - return $scope->getVariableType($expr->name); - } + return $readScope->getVariableType($expr->name); + } - $nameType = $scope->getType($expr->name); - if (count($nameType->getConstantStrings()) > 0) { - $types = []; - foreach ($nameType->getConstantStrings() as $constantString) { - $variableScope = $scope - ->filterByTruthyValue( - new Identical($expr->name, new String_($constantString->getValue())), + // this branch is only reached when $expr->name is an Expr, which is + // exactly when the caller (processExpr) set $nameResult + if ($nameResult === null) { + throw new ShouldNotHappenException(); + } + $nameType = $nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType(); + if (count($nameType->getConstantStrings()) > 0) { + $types = []; + foreach ($nameType->getConstantStrings() as $constantString) { + // "name === 'str'" composed from the name expression's walk + // result - no synthetic Identical walk; the literal side is a + // result the scalar handler would have produced + $literalExpr = new String_($constantString->getValue()); + $literalResult = $this->expressionResultFactory->create( + $readScope, + beforeScope: $readScope, + expr: $literalExpr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (): Type => $constantString, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); - if ($variableScope->hasVariableType($constantString->getValue())->no()) { - $types[] = new ErrorType(); - continue; + $specifiedTypes = $this->identicalNarrowingHelper->specifyIdentical( + $nodeScopeResolver, + $expr->name, + $literalExpr, + $nameResult, + $literalResult, + TypeSpecifierContext::createTruthy(), + $readScope, + $nameArgResult, + null, + fn (): Type => $this->initializerExprTypeResolver->resolveIdenticalType($nameType, $constantString)->type, + ); + $variableScope = $readScope->applySpecifiedTypes($specifiedTypes ?? new SpecifiedTypes()); + if ($variableScope->hasVariableType($constantString->getValue())->no()) { + $types[] = new ErrorType(); + continue; + } + + $types[] = $variableScope->getVariableType($constantString->getValue()); } - $types[] = $variableScope->getVariableType($constantString->getValue()); + return TypeCombinator::union(...$types); } - return TypeCombinator::union(...$types); - } - - return new MixedType(); + return new MixedType(); + }; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult @@ -85,7 +130,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); } - return $this->composeResult($expr, $nameResult, $beforeScope); + return $this->composeResult($nodeScopeResolver, $expr, $nameResult, $storage, $beforeScope); } /** @@ -94,7 +139,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex * walking a dynamic name; AssignHandler::prepareTarget() calls it to price a * read-modify-write target without re-walking it. */ - public function composeResult(Variable $expr, ?ExpressionResult $nameResult, MutatingScope $beforeScope): ExpressionResult + public function composeResult(NodeScopeResolver $nodeScopeResolver, Variable $expr, ?ExpressionResult $nameResult, ExpressionResultStorage $storage, MutatingScope $beforeScope): ExpressionResult { $scope = $beforeScope; $hasYield = false; @@ -112,23 +157,19 @@ public function composeResult(Variable $expr, ?ExpressionResult $nameResult, Mut $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); $scope = $nameResult->getScope(); } + return $this->expressionResultFactory->create( $scope, - $beforeScope, - $expr, - $hasYield, - $isAlwaysTerminating, - $throwPoints, - $impurePoints, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, issetabilityDescriptor: is_string($expr->name) ? IssetabilityDescriptor::variable($expr->name) : null, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + typeCallback: $this->createTypeCallback($expr, $nodeScopeResolver, $beforeScope, $nameResult, is_string($expr->name) ? null : $this->identicalNarrowingHelper->captureFirstArgResult($expr->name, $storage)), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } From e860e0208a64d63c7290b640ba0abc58a63febfc Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:36 +0200 Subject: [PATCH 13/32] Resolve isset, empty and coalesce from chain results The isset/empty/coalesce family stops re-walking its chains: the chain links' results are captured during the single walk, isset narrowing entries are built by DefaultNarrowingHelper from those results, empty($x) becomes an explicit !isset($x) || !$x disjunction through the boolean helper with IssetabilityResolution::notEmpty() supplying the type, and ?? composes both type and narrowing from the two sides' results per flavour (covered by the native-flavour fixture). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/ExprHandler/CoalesceHandler.php | 157 +++--- src/Analyser/ExprHandler/EmptyHandler.php | 155 ++++-- .../Helper/CoalesceCompositionHelper.php | 140 ++++++ src/Analyser/ExprHandler/IssetHandler.php | 447 ++++++------------ src/Analyser/IssetabilityResolution.php | 24 + .../Analyser/nsrt/coalesce-native-type.php | 29 ++ 6 files changed, 527 insertions(+), 425 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php create mode 100644 tests/PHPStan/Analyser/nsrt/coalesce-native-type.php diff --git a/src/Analyser/ExprHandler/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index c0a54c5bee3..6cef6945c8b 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -10,21 +10,19 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\CoalesceCompositionHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\CoalesceExpressionNode; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use function array_merge; /** @@ -37,6 +35,8 @@ final class CoalesceHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private CoalesceCompositionHelper $coalesceCompositionHelper, ) { } @@ -46,78 +46,6 @@ public function supports(Expr $expr): bool return $expr instanceof Coalesce; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $issetLeftExpr = new Expr\Isset_([$expr->left]); - - $result = $scope->issetCheck($expr->left, static function (Type $type): ?bool { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } - - return !$isNull->yes(); - }); - - if ($result !== null && $result !== false) { - return TypeCombinator::removeNull($scope->filterByTruthyValue($issetLeftExpr)->getType($expr->left)); - } - - $rightType = $scope->filterByFalseyValue($issetLeftExpr)->getType($expr->right); - - if ($result === null) { - return TypeCombinator::union( - TypeCombinator::removeNull($scope->filterByTruthyValue($issetLeftExpr)->getType($expr->left)), - $rightType, - ); - } - - return $rightType; - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - if (!$context->true()) { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - $isset = $scope->issetCheck($expr->left, static fn () => true); - - if ($isset !== true) { - return new SpecifiedTypes(); - } - - return $typeSpecifier->create( - $expr->left, - new NullType(), - $context->negate(), - $scope, - )->setRootExpr($expr); - } - - if ( - !$context->falsey() - && (new ConstantBooleanType(false))->isSuperTypeOf($scope->getType($expr->right)->toBoolean())->yes() - ) { - return $typeSpecifier->create( - $expr->left, - new NullType(), - TypeSpecifierContext::createFalse(), - $scope, - )->setRootExpr($expr); - } - - // The Coalesce condition matched but produced no narrowing; the legacy - // if/elseif chain fell through to its empty-SpecifiedTypes tail here, - // not to the truthy/falsey default. - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -127,13 +55,28 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($condResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->left); - $rightScope = $scope->filterByFalseyValue($expr); + // the falsey narrowing of this very node - asking the scope about it + // mid-processing would take the on-demand path and recurse + $rightScope = $scope->applySpecifiedTypes($this->coalesceCompositionHelper->getFalseySpecifiedTypes($scope, $scope, $expr->left, $condResult, $expr, TypeSpecifierContext::createFalsey())); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); + // the left-is-set narrowing, composed from the already-processed chain + // results - the inside-out equivalent of narrowing by isset($expr->left) + // without synthesizing an Isset_ node and re-walking the chain on demand + $chainResults = []; + $this->defaultNarrowingHelper->captureChainResults($expr->left, $storage, $chainResults); + $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( + $scope, + $expr->left, + $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $scope), + $expr, + TypeSpecifierContext::createTruthy(), + ); + $rightExprType = $rightResult->getType(); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left])); + $scope = $scope->applySpecifiedTypes($leftIssetTypes); } else { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left]))->mergeWith($rightResult->getScope()); + $scope = $scope->applySpecifiedTypes($leftIssetTypes)->mergeWith($rightResult->getScope()); } $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $condResult, 'on left side of ??'), $beforeScope, $storage, $context); @@ -146,6 +89,62 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->coalesceCompositionHelper->composeType( + $nodeScopeResolver, + $expr->left, + $condResult, + $rightResult, + // the isset resolution and the left-is-set narrowing run on + // beforeScope (the evaluation point), not the asking scope. + $beforeScope, + $chainResults, + $expr, + $nativeTypesPromoted, + ), + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $beforeScope): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if (!$context->true()) { + return $this->coalesceCompositionHelper->getFalseySpecifiedTypes($s, $s, $expr->left, $condResult, $expr, $context); + } + + if ( + !$context->falsey() + && (new ConstantBooleanType(false))->isSuperTypeOf(($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType())->toBoolean())->yes() + ) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), TypeSpecifierContext::createFalse())->setRootExpr($expr); + } + + // The Coalesce condition matched but produced no narrowing; the legacy + // if/elseif chain fell through to its empty-SpecifiedTypes tail here, + // not to the truthy/falsey default. + return (new SpecifiedTypes([], []))->setRootExpr($expr); + }, + // a type constraint on the coalesce constrains its left side when + // the type rules the right side in or out - what + // TypeSpecifier::create() recovered by unwrapping the coalesce + createTypesCallback: function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if (!$context->null()) { + $rightType = $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); + if ( + ($context->true() && $type->isSuperTypeOf($rightType)->no()) + || ($context->false() && $type->isSuperTypeOf($rightType)->yes()) + ) { + // the coalesce's own key is emitted alongside the left-side + // narrowing (createForExpr's double-key, like the nullsafe + // handlers) - consumers summing the checked expression's own + // entry (ImpossibleCheckTypeHelper) rely on it + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, $type, $context) + ->unionWith($this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)); + } + } + + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context); + }, ); } diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 45e30427624..c0f643e7730 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -3,7 +3,6 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\Empty_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -11,16 +10,15 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\EmptyExpressionNode; -use PHPStan\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Type; @@ -35,6 +33,8 @@ final class EmptyHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -44,48 +44,6 @@ public function supports(Expr $expr): bool return $expr instanceof Empty_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $result = $scope->issetCheck($expr->expr, static function (Type $type): ?bool { - $isNull = $type->isNull(); - $isFalsey = $type->toBoolean()->isFalse(); - if ($isNull->maybe()) { - return null; - } - if ($isFalsey->maybe()) { - return null; - } - - if ($isNull->yes()) { - return $isFalsey->no(); - } - - return !$isFalsey->yes(); - }); - if ($result === null) { - return new BooleanType(); - } - - return new ConstantBooleanType(!$result); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - $isset = $scope->issetCheck($expr->expr, static fn () => true); - if ($isset === false) { - return new SpecifiedTypes(); - } - - return $typeSpecifier->specifyTypesInCondition($scope, new BooleanOr( - new Expr\BooleanNot(new Expr\Isset_([$expr->expr])), - new Expr\BooleanNot($expr->expr), - ), $context)->setRootExpr($expr); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -96,8 +54,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->expr); + $chainResults = []; + $this->defaultNarrowingHelper->captureChainResults($expr->expr, $storage, $chainResults); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new EmptyExpressionNode($expr, $exprResult), $beforeScope, $storage, $context); + // lazily memoized branch scopes of the !isset($x) || !$x decomposition + /** @var array{MutatingScope, MutatingScope, MutatingScope}|null $foldScopes */ + $foldScopes = null; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -106,6 +71,104 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), + typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult, $beforeScope): Type { + $result = $exprResult->getIssetabilityResolution($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, false)->notEmpty(); + if ($result === null) { + return new BooleanType(); + } + + return new ConstantBooleanType(!$result); + }, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $exprResult, $chainResults, $nodeScopeResolver, $beforeScope, &$foldScopes): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $isset = $exprResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + if ($isset === false) { + return new SpecifiedTypes(); + } + + // empty($x) narrows like !isset($x) || !$x, composed through the + // disjunction helper - the fabricated nodes are only printed + // into holder keys, never walked + $issetNode = new Expr\Isset_([$expr->expr]); + $notIssetNode = new Expr\BooleanNot($issetNode); + $notExprNode = new Expr\BooleanNot($expr->expr); + + $leftTypes = function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($chainResults, $expr, $exprResult, $issetNode, $notIssetNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notIssetNode, $ctx); + } + $negated = $ctx->negate(); + $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $scope); + if (!$negated->true()) { + return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes($scope, $expr->expr, $exprResult, $readType, $negated, $issetNode); + } + + return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($scope, $expr->expr, $readType, $issetNode, $negated); + }; + $leftType = static function (bool $nativeTypesPromoted) use ($exprResult, $beforeScope): Type { + $issetabilityScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $result = $exprResult->getIssetabilityResolution($issetabilityScope, false)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + if ($result === null) { + return new BooleanType(); + } + + return new ConstantBooleanType(!$result); + }; + $rightTypes = function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($exprResult, $notExprNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notExprNode, $ctx); + } + + return $exprResult->getSpecifiedTypesForScope($scope, $ctx->negate()); + }; + $rightType = static function (bool $nativeTypesPromoted) use ($exprResult): Type { + $bool = ($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType())->toBoolean(); + if ($bool->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($bool->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + + // the disjuncts' branch scopes derive from the evaluation point, + // not the asking scope - computed once, reused across asks + if ($foldScopes === null) { + $leftTruthyScope = $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createTruthy())); + $leftFalseyScope = $beforeScope->applySpecifiedTypes($leftTypes($beforeScope, TypeSpecifierContext::createFalsey())); + $foldScopes = [ + $leftTruthyScope, + $leftFalseyScope, + $leftFalseyScope->applySpecifiedTypes($rightTypes($leftFalseyScope, TypeSpecifierContext::createTruthy())), + ]; + } + [$leftTruthyScope, $leftFalseyScope, $rightTruthyScope] = $foldScopes; + + return $this->booleanNarrowingHelper->specifyDisjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $notIssetNode, + $leftTypes, + $leftType, + static fn (): MutatingScope => $leftTruthyScope, + static fn (): MutatingScope => $leftFalseyScope, + $notExprNode, + $rightTypes, + $rightType, + static fn (): MutatingScope => $rightTruthyScope, + )->setRootExpr($expr); + }, ); } diff --git a/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php new file mode 100644 index 00000000000..41948e1e668 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php @@ -0,0 +1,140 @@ +getIssetabilityResolution($evaluationScope, false)->isSet(static fn (): bool => true); + + if ($isset !== true) { + return new SpecifiedTypes(); + } + + return $this->defaultNarrowingHelper->createSubjectTypes($s, $leftExpr, $leftResult, new NullType(), $context->negate())->setRootExpr($rootExpr); + } + + /** + * The right side of a coalesce only evaluates when the left side is null + * or unset - the falsey narrowing of isset($leftExpr) composed from the + * left read (a certainty reduction for surely-set non-nullable subjects, + * not a bare null pin - mirrors filtering the right-side scope by falsey + * `isset()` instead of `!== null`). + * + * @param array $chainResults + */ + public function getRightSideScopeSpecifiedTypes(MutatingScope $s, Expr $leftExpr, ExpressionResult $leftResult, array $chainResults, Expr $rootExpr): SpecifiedTypes + { + return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes( + $s, + $leftExpr, + $leftResult, + $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $s), + TypeSpecifierContext::createFalsey(), + $rootExpr, + ); + } + + /** + * The `??`'s own type: the left side when it is surely set and non-null, + * the right side when it surely is not, their union otherwise. Runs on the + * evaluation scope (where the sides were walked), not the asking scope. + * + * @param array $chainResults + */ + public function composeType( + NodeScopeResolver $nodeScopeResolver, + Expr $leftExpr, + ExpressionResult $leftResult, + ExpressionResult $rightResult, + MutatingScope $evaluationScope, + array $chainResults, + Expr $rootExpr, + bool $nativeTypesPromoted, + ): Type + { + // the whole resolution runs in the asked flavour - the native ask maps + // the evaluation scope once and every read below follows it, so the + // phpdoc left type never leaks into the native answer + if ($nativeTypesPromoted) { + $evaluationScope = $evaluationScope->doNotTreatPhpDocTypesAsCertain(); + } + $result = $leftResult->getIssetabilityResolution($evaluationScope, $nativeTypesPromoted)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + + // the left side's type when it is set: the left read on the left-is-set + // narrowed scope (offsets resolve against the HasOffset-narrowed parent). + // The narrowing is tracked by the scope (getTypeOnScope's authoritative + // read); only an untracked left side needs reprocessing there. + $leftIsSetType = function () use ($leftExpr, $leftResult, $nodeScopeResolver, $evaluationScope, $chainResults, $rootExpr, $nativeTypesPromoted): Type { + $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( + $evaluationScope, + $leftExpr, + $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $evaluationScope), + $rootExpr, + TypeSpecifierContext::createTruthy(), + ); + $leftIsSetScope = $evaluationScope->applySpecifiedTypes($leftIssetTypes); + $leftType = $leftResult->answersOnScope($leftIsSetScope, $nativeTypesPromoted) + ? $leftResult->getTypeOnScope($leftIsSetScope, $nativeTypesPromoted) + : $nodeScopeResolver->processExprOnDemand($leftExpr, $leftIsSetScope, new ExpressionResultStorage())->getTypeOnScope($leftIsSetScope, $nativeTypesPromoted); + + return TypeCombinator::removeNull($leftType); + }; + + if ($result !== null && $result !== false) { + return $leftIsSetType(); + } + + // the right side was processed on the left-is-null scope, so its own + // result is the evaluation point. + $rightType = $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); + + if ($result === null) { + return TypeCombinator::union($leftIsSetType(), $rightType); + } + + return $rightType; + } + +} diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 82c1ae5af79..02961c56e16 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -3,54 +3,36 @@ namespace PHPStan\Analyser\ExprHandler; use ArrayAccess; +use Closure; use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrayDimFetch; use PhpParser\Node\Expr\BinaryOp\BooleanAnd; use PhpParser\Node\Expr\Isset_; use PhpParser\Node\Expr\MethodCall; -use PhpParser\Node\Expr\PropertyFetch; -use PhpParser\Node\Expr\StaticPropertyFetch; -use PhpParser\Node\Identifier; use PhpParser\Node\Stmt; -use PhpParser\Node\VarLikeIdentifier; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; -use PHPStan\Node\IssetExpr; use PHPStan\Node\IssetExpressionNode; -use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; -use PHPStan\ShouldNotHappenException; -use PHPStan\Type\Accessory\HasOffsetType; -use PHPStan\Type\Accessory\HasPropertyType; -use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; -use PHPStan\Type\Constant\ConstantIntegerType; -use PHPStan\Type\Constant\ConstantStringType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\MixedType; -use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; -use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; use function array_merge; use function array_reverse; -use function array_shift; use function count; -use function is_string; /** * @implements ExprHandler @@ -63,6 +45,8 @@ public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, private MethodThrowPointHelper $methodThrowPointHelper, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -72,284 +56,6 @@ public function supports(Expr $expr): bool return $expr instanceof Isset_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $issetResult = true; - foreach ($expr->vars as $var) { - $result = $scope->issetCheck($var, static function (Type $type): ?bool { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } - - return !$isNull->yes(); - }); - if ($result !== null) { - if (!$result) { - return new ConstantBooleanType($result); - } - - continue; - } - - $issetResult = $result; - } - - if ($issetResult === null) { - return new BooleanType(); - } - - return new ConstantBooleanType($issetResult); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (count($expr->vars) === 0 || $context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - // rewrite multi param isset() to and-chained single param isset() - if (count($expr->vars) > 1) { - $issets = []; - foreach ($expr->vars as $var) { - $issets[] = new Isset_([$var], $expr->getAttributes()); - } - - $first = array_shift($issets); - $andChain = null; - foreach ($issets as $isset) { - if ($andChain === null) { - $andChain = new BooleanAnd($first, $isset); - continue; - } - - $andChain = new BooleanAnd($andChain, $isset); - } - - if ($andChain === null) { - throw new ShouldNotHappenException(); - } - - return $typeSpecifier->specifyTypesInCondition($scope, $andChain, $context)->setRootExpr($expr); - } - - $issetExpr = $expr->vars[0]; - - if (!$context->true()) { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - $isset = $scope->issetCheck($issetExpr, static fn () => true); - - if ($isset === false) { - return new SpecifiedTypes(); - } - - $type = $scope->getType($issetExpr); - $isNullable = !$type->isNull()->no(); - $exprType = $typeSpecifier->create( - $issetExpr, - new NullType(), - $context->negate(), - $scope, - )->setRootExpr($expr); - - if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { - if ($isset === true) { - if ($isNullable) { - return $exprType; - } - - // variable cannot exist in !isset() - return $exprType->unionWith($typeSpecifier->create( - new IssetExpr($issetExpr), - new NullType(), - $context, - $scope, - ))->setRootExpr($expr); - } - - if ($isNullable) { - // reduces variable certainty to maybe - return $exprType->unionWith($typeSpecifier->create( - new IssetExpr($issetExpr), - new NullType(), - $context->negate(), - $scope, - ))->setRootExpr($expr); - } - - // variable cannot exist in !isset() - return $typeSpecifier->create( - new IssetExpr($issetExpr), - new NullType(), - $context, - $scope, - )->setRootExpr($expr); - } - - if ($isNullable && $isset === true) { - return $exprType; - } - - if ( - $issetExpr instanceof ArrayDimFetch - && $issetExpr->dim !== null - // When the var is itself an offset access (a nested isset like - // $r['K']['Port']), narrowing it in the falsey branch leaks the - // intermediate offset's existence into the enclosing scope. - && !($issetExpr->var instanceof ArrayDimFetch) - ) { - $varType = $scope->getType($issetExpr->var); - if (!$varType instanceof MixedType) { - $dimType = $scope->getType($issetExpr->dim); - - if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - $constantArrays = $varType->getConstantArrays(); - $typesToRemove = []; - foreach ($constantArrays as $constantArray) { - $hasOffset = $constantArray->hasOffsetValueType($dimType); - if (!$hasOffset->yes() || !$constantArray->getOffsetValueType($dimType)->isNull()->no()) { - continue; - } - - $typesToRemove[] = $constantArray; - } - - if ($typesToRemove !== []) { - $typeToRemove = TypeCombinator::union(...$typesToRemove); - - $result = $typeSpecifier->create( - $issetExpr->var, - $typeToRemove, - TypeSpecifierContext::createFalse(), - $scope, - )->setRootExpr($expr); - - if ($scope->hasExpressionType($issetExpr->var)->maybe()) { - $result = $result->unionWith( - $typeSpecifier->create( - new IssetExpr($issetExpr->var), - new NullType(), - TypeSpecifierContext::createTruthy(), - $scope, - )->setRootExpr($expr), - ); - } - - return $result; - } - } - } - } - - return new SpecifiedTypes(); - } - - $tmpVars = [$issetExpr]; - while ( - $issetExpr instanceof ArrayDimFetch - || $issetExpr instanceof PropertyFetch - || ( - $issetExpr instanceof StaticPropertyFetch - && $issetExpr->class instanceof Expr - ) - ) { - if ($issetExpr instanceof StaticPropertyFetch) { - /** @var Expr $issetExpr */ - $issetExpr = $issetExpr->class; - } else { - $issetExpr = $issetExpr->var; - } - $tmpVars[] = $issetExpr; - } - $vars = array_reverse($tmpVars); - - $types = new SpecifiedTypes(); - foreach ($vars as $var) { - - if ($var instanceof Expr\Variable && is_string($var->name)) { - if ($scope->hasVariableType($var->name)->no()) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); - } - } - - if ( - $var instanceof ArrayDimFetch - && $var->dim !== null - && !$scope->getType($var->var) instanceof MixedType - ) { - $dimType = $scope->getType($var->dim); - - if ($dimType instanceof ConstantIntegerType || $dimType instanceof ConstantStringType) { - $types = $types->unionWith( - $typeSpecifier->create( - $var->var, - new HasOffsetType($dimType), - $context, - $scope, - )->setRootExpr($expr), - ); - } else { - $varType = $scope->getType($var->var); - - $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); - if ($narrowedKey !== null) { - $types = $types->unionWith( - $typeSpecifier->create( - $var->dim, - $narrowedKey, - $context, - $scope, - )->setRootExpr($expr), - ); - } - - if ($varType->isArray()->yes()) { - $types = $types->unionWith( - $typeSpecifier->create( - $var->var, - new NonEmptyArrayType(), - $context, - $scope, - )->setRootExpr($expr), - ); - } - } - } - - if ( - $var instanceof PropertyFetch - && $var->name instanceof Identifier - ) { - $types = $types->unionWith( - $typeSpecifier->create($var->var, new IntersectionType([ - new ObjectWithoutClassType(), - new HasPropertyType($var->name->toString()), - ]), TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($expr), - ); - } elseif ( - $var instanceof StaticPropertyFetch - && $var->class instanceof Expr - && $var->name instanceof VarLikeIdentifier - ) { - $types = $types->unionWith( - $typeSpecifier->create($var->class, new IntersectionType([ - new ObjectWithoutClassType(), - new HasPropertyType($var->name->toString()), - ]), TypeSpecifierContext::createTruthy(), $scope)->setRootExpr($expr), - ); - } - - $types = $types->unionWith( - $typeSpecifier->create($var, new NullType(), TypeSpecifierContext::createFalse(), $scope)->setRootExpr($expr), - ); - } - - return $types; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -375,7 +81,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex continue; } - $varType = $scope->getType($var->var); + $varType = $nodeScopeResolver->readStoredResult($var->var, $storage)->getTypeOnScope($scope, false); if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { continue; } @@ -394,8 +100,28 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); } + // The subjects and their chain links were just processed, so their + // ExpressionResults are in the storage; capture them (the results, not the + // storage - no reference cycle) so the narrowing reads their types via + // getTypeOnScope() instead of re-walking through Scope::getType(). + $chainResults = []; + foreach ($expr->vars as $var) { + $this->defaultNarrowingHelper->captureChainResults($var, $storage, $chainResults); + } + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new IssetExpressionNode($expr, $varResults), $beforeScope, $storage, $context); + // The verdict and narrowing evaluate on the post-revert scope, not + // $beforeScope: revertNonNullability() leaves an originally-untracked + // nullable subject tracked at its original type (certainty yes), and the + // isSet() gate reads that as "the subject's value state is known" - + // !isset($this->prop) may then pin the property to null. Evaluating on + // $beforeScope would hide the device's holders from the gate. + $afterScope = $scope; + + // lazily memoized multi-subject conjunction fold (ask-independent) + $foldAccTypes = null; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -404,6 +130,127 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: static function (bool $nativeTypesPromoted) use ($varResults, $afterScope): Type { + $issetResult = true; + foreach ($varResults as $varResult) { + $result = $varResult->getIssetabilityResolution($nativeTypesPromoted ? $afterScope->doNotTreatPhpDocTypesAsCertain() : $afterScope, false)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + if ($result !== null) { + if (!$result) { + return new ConstantBooleanType($result); + } + + continue; + } + + $issetResult = $result; + } + + if ($issetResult === null) { + return new BooleanType(); + } + + return new ConstantBooleanType($issetResult); + }, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $varResults, $chainResults, $nodeScopeResolver, $afterScope, &$foldAccTypes): SpecifiedTypes { + // type of an already-processed chain link, read from its captured + // result on the evaluation point - never re-walked through the scope + $evaluationScope = $nativeTypesPromoted ? $afterScope->doNotTreatPhpDocTypesAsCertain() : $afterScope; + $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $evaluationScope); + + if (count($expr->vars) === 0 || $context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + if (count($expr->vars) > 1) { + // isset($a, $b) is true only when every subject is set - the + // truthy narrowing is the union of each subject's own truthy + // chain narrowing, composed directly from the captured results + if ($context->true()) { + $types = new SpecifiedTypes(); + foreach ($expr->vars as $var) { + $types = $types->unionWith( + $this->defaultNarrowingHelper->createIssetTruthyChainTypes($evaluationScope, $var, $readType, $expr, $context), + ); + } + + return $types->setRootExpr($expr); + } + + // non-true contexts (only SOME subject is unset): fold the + // subjects through the conjunction narrowing; the fabricated + // Isset_/BooleanAnd nodes are only printed into holder keys, + // never walked + $makeSubjectTypes = fn (Expr $var, ExpressionResult $varResult): Closure => function (MutatingScope $scope, TypeSpecifierContext $ctx) use ($chainResults, $expr, $var, $varResult): SpecifiedTypes { + $scopedReadType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $scope); + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes(new Isset_([$var], $expr->getAttributes()), $ctx); + } + if (!$ctx->true()) { + return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes($scope, $var, $varResult, $scopedReadType, $ctx, $expr); + } + + return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($scope, $var, $scopedReadType, $expr, $ctx); + }; + + // the fold's branch scopes derive from the evaluation point, + // not the asking scope - the accumulated conjunction closure + // is ask-independent and built once, reused across asks + if ($foldAccTypes !== null) { + return $foldAccTypes($evaluationScope, $context)->setRootExpr($expr); + } + + $accExpr = new Isset_([$expr->vars[0]], $expr->getAttributes()); + $accTypes = $makeSubjectTypes($expr->vars[0], $varResults[0]); + $accTruthyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createTruthy())); + $accFalseyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createFalsey())); + + for ($i = 1, $varCount = count($expr->vars); $i < $varCount; $i++) { + $rightExprNode = new Isset_([$expr->vars[$i]], $expr->getAttributes()); + $rightTypes = $makeSubjectTypes($expr->vars[$i], $varResults[$i]); + $rightFalseyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createFalsey())); + + $leftExprNode = $accExpr; + $leftTypes = $accTypes; + $leftTruthyScope = $accTruthyScope; + $leftFalseyScope = $accFalseyScope; + $accTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $scope, + $ctx, + $expr, + $leftExprNode, + $leftTypes, + static fn (): MutatingScope => $leftTruthyScope, + static fn (): MutatingScope => $leftFalseyScope, + $rightExprNode, + $rightTypes, + static fn (): MutatingScope => $rightFalseyScope, + ); + $accExpr = new BooleanAnd($leftExprNode, $rightExprNode); + $accTruthyScope = $accTruthyScope->applySpecifiedTypes($rightTypes($accTruthyScope, TypeSpecifierContext::createTruthy())); + $accFalseyScope = $afterScope->applySpecifiedTypes($accTypes($afterScope, TypeSpecifierContext::createFalsey())); + } + + $foldAccTypes = $accTypes; + + return $accTypes($evaluationScope, $context)->setRootExpr($expr); + } + + $issetExpr = $expr->vars[0]; + + if (!$context->true()) { + return $this->defaultNarrowingHelper->createIssetSingleSubjectNonTrueTypes($evaluationScope, $issetExpr, $varResults[0], $readType, $context, $expr); + } + + return $this->defaultNarrowingHelper->createIssetTruthyChainTypes($evaluationScope, $issetExpr, $readType, $expr, $context); + }, ); } diff --git a/src/Analyser/IssetabilityResolution.php b/src/Analyser/IssetabilityResolution.php index cd96bf1913d..6a94eeed827 100644 --- a/src/Analyser/IssetabilityResolution.php +++ b/src/Analyser/IssetabilityResolution.php @@ -150,4 +150,28 @@ private function isSetUndefined(): ?bool return null; } + /** + * Whether empty() of the whole chain is surely false (i.e. set and not falsy); + * null = maybe. EmptyHandler negates the result. + */ + public function notEmpty(): ?bool + { + return $this->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + $isFalsey = $type->toBoolean()->isFalse(); + if ($isNull->maybe()) { + return null; + } + if ($isFalsey->maybe()) { + return null; + } + + if ($isNull->yes()) { + return $isFalsey->no(); + } + + return !$isFalsey->yes(); + }); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/coalesce-native-type.php b/tests/PHPStan/Analyser/nsrt/coalesce-native-type.php new file mode 100644 index 00000000000..4c7051baf8a --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/coalesce-native-type.php @@ -0,0 +1,29 @@ + Date: Fri, 14 Aug 2026 19:11:36 +0200 Subject: [PATCH 14/32] Decompose ternary and match through composed narrowing TernaryHandler decomposes c ? a : b into (c && a) || (!c && b) through the boolean helpers with thunked branch scopes, and caches the three operand results per node for the assignment handler's conditional holders. MatchHandler narrows arm conditions through composed specifyIdentical() with a threaded per-arm subject state and unions the already-walked arm results; exhaustive matches over nullable enums no longer produce an UnhandledMatchError throw point. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/ExprHandler/MatchHandler.php | 297 +++++++++--------- src/Analyser/ExprHandler/TernaryHandler.php | 280 +++++++++++++---- .../PHPStan/Rules/Exceptions/Bug14396Test.php | 46 +++ tests/PHPStan/Rules/Exceptions/bug-14396.neon | 5 + .../Rules/Exceptions/data/bug-14396.php | 45 +++ 5 files changed, 464 insertions(+), 209 deletions(-) create mode 100644 tests/PHPStan/Rules/Exceptions/Bug14396Test.php create mode 100644 tests/PHPStan/Rules/Exceptions/bug-14396.neon create mode 100644 tests/PHPStan/Rules/Exceptions/data/bug-14396.php diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index a0d69d5d70d..a451b7d1178 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -19,12 +19,14 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; +use PHPStan\Analyser\PerFileAnalysisResettable; +use PHPStan\Analyser\RicherScopeGetTypeHelper; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -45,6 +47,7 @@ use function array_values; use function count; use function ksort; +use function spl_object_id; use function strtolower; use const SORT_NUMERIC; @@ -52,13 +55,29 @@ * @implements ExprHandler */ #[AutowiredService] -final class MatchHandler implements ExprHandler +final class MatchHandler implements ExprHandler, PerFileAnalysisResettable { + /** + * Keyed by the match node's spl_object_id() - see + * TernaryHandler::$capturedResults for the lifetime/collision reasoning. + * + * @var array> + */ + private array $capturedArmResults = []; + + public function resetFileAnalysisState(): void + { + $this->capturedArmResults = []; + } + public function __construct( #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, + private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, ) { } @@ -68,143 +87,29 @@ public function supports(Expr $expr): bool return $expr instanceof Match_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $types = []; - foreach ($this->getArmScopesAndTypes($scope, $expr) as [$armScope, $armType]) { - $types[] = $armType; - } - - return TypeCombinator::union(...$types); - } - /** - * For each reachable match arm, returns the arm's body type together with the - * scope in which the match subject is narrowed to that arm's condition. This - * lets callers reconstruct the relationship between the match result and the + * For each reachable arm of an already-processed match, the arm's body type + * together with the scope in which the subject is narrowed to that arm's + * condition - the pairs captured during processExpr()'s single walk. Lets + * callers reconstruct the relationship between the match result and the * narrowed subject (e.g. to project a later narrowing of the assigned result - * back onto the subject). + * back onto the subject) without re-walking the arms. Null when the node was + * never processed. * - * @return list + * @return list|null */ - public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array + public function getCapturedArmScopesAndTypes(Match_ $expr): ?array { - $cond = $expr->cond; - $condType = $scope->getType($cond); - $armScopesAndTypes = []; - - $matchScope = $scope; - $arms = $expr->arms; - if ($condType->isEnum()->yes()) { - // enum match analysis would work even without this if branch - // but would be much slower - // this avoids using ObjectType::$subtractedType which is slow for huge enums - // because of repeated union type normalization - $enumCases = $condType->getEnumCases(); - if (count($enumCases) > 0) { - $indexedEnumCases = []; - foreach ($enumCases as $enumCase) { - $indexedEnumCases[strtolower($enumCase->getClassName())][$enumCase->getEnumCaseName()] = $enumCase; - } - $unusedIndexedEnumCases = $indexedEnumCases; - - foreach ($arms as $i => $arm) { - if ($arm->conds === null) { - continue; - } - - $conditionCases = []; - foreach ($arm->conds as $armCond) { - if (!$armCond instanceof Expr\ClassConstFetch) { - continue 2; - } - if (!$armCond->class instanceof Name) { - continue 2; - } - if (!$armCond->name instanceof Identifier) { - continue 2; - } - $fetchedClassName = $scope->resolveName($armCond->class); - $loweredFetchedClassName = strtolower($fetchedClassName); - if (!array_key_exists($loweredFetchedClassName, $indexedEnumCases)) { - continue 2; - } - - $caseName = $armCond->name->toString(); - if (!array_key_exists($caseName, $indexedEnumCases[$loweredFetchedClassName])) { - continue 2; - } - - $conditionCases[] = $indexedEnumCases[$loweredFetchedClassName][$caseName]; - unset($unusedIndexedEnumCases[$loweredFetchedClassName][$caseName]); - } - - $conditionCasesCount = count($conditionCases); - if ($conditionCasesCount === 0) { - throw new ShouldNotHappenException(); - } elseif ($conditionCasesCount === 1) { - $conditionCaseType = $conditionCases[0]; - } else { - $conditionCaseType = new UnionType($conditionCases); - } - - $armScope = $matchScope->addTypeToExpression( - $cond, - $conditionCaseType, - ); - $armScopesAndTypes[] = [$armScope, $armScope->getType($arm->body)]; - unset($arms[$i]); - } - - $remainingCases = []; - foreach ($unusedIndexedEnumCases as $cases) { - foreach ($cases as $case) { - $remainingCases[] = $case; - } - } - - $remainingCasesCount = count($remainingCases); - if ($remainingCasesCount === 0) { - $remainingType = new NeverType(); - } elseif ($remainingCasesCount === 1) { - $remainingType = $remainingCases[0]; - } else { - $remainingType = new UnionType($remainingCases); - } - - $matchScope = $matchScope->addTypeToExpression($cond, $remainingType); - } + if (!isset($this->capturedArmResults[spl_object_id($expr)])) { + return null; } - foreach ($arms as $arm) { - if ($arm->conds === null) { - if ($expr->hasAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)) { - $arm->body->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)); - } - $armScopesAndTypes[] = [$matchScope, $matchScope->getType($arm->body)]; - continue; - } - - if (count($arm->conds) === 0) { - throw new ShouldNotHappenException(); - } - - $filteringExpr = $this->getFilteringExprForMatchArm($expr, $arm->conds); - - $filteringExprType = $matchScope->getType($filteringExpr); - - if (!$filteringExprType->isFalse()->yes()) { - $truthyScope = $matchScope->filterByTruthyValue($filteringExpr); - if ($expr->hasAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)) { - $arm->body->setAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME, $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME)); - } - $armScopesAndTypes[] = [$truthyScope, $truthyScope->getType($arm->body)]; - } - - $matchScope = $matchScope->filterByFalseyValue($filteringExpr); + $pairs = []; + foreach ($this->capturedArmResults[spl_object_id($expr)] as [$armResult, $bodyScope]) { + $pairs[] = [$bodyScope, $armResult->getType()]; } - return $armScopesAndTypes; + return $pairs; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult @@ -212,6 +117,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $beforeScope = $scope; $deepContext = $context->enterDeep(); $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->cond, $scope, $storage, $nodeCallback, $deepContext); + // the subject was just processed on this scope; read its result instead of + // re-walking via Scope::getType(). $condType = $condResult->getType(); $condNativeType = $condResult->getNativeType(); $scope = $condResult->getScope(); @@ -226,6 +133,16 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $arms = $expr->arms; $armCondsToSkip = []; $armBodyScopes = []; + // Capture, for each reachable arm, the body's already-computed + // ExpressionResult together with the scope it was processed on and the + // body node itself. The typeCallback unions these inside-out instead of + // re-walking the arms (which getArmScopesAndTypes/the old resolveType + // did). The set of contributing arms mirrors getArmScopesAndTypes + // exactly. The body node is kept so the keepVoid projection (the only + // caller is getKeepVoidType, via a synthetic clone of the match) can be + // computed for it. + /** @var list $armTypeResults */ + $armTypeResults = []; if ($condType->isEnum()->yes()) { // enum match analysis would work even without this if branch // but would be much slower @@ -345,10 +262,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $filteringExpr = $this->getFilteringExprForMatchArm($expr, $conditionExprs); - $matchArmBodyScope = $matchScope->addTypeToExpression( + $condNarrowedScope = $matchScope->addTypeToExpression( $expr->cond, $conditionCaseType, - )->filterByTruthyValue($filteringExpr); + ); + $matchArmBodyScope = $condNarrowedScope->applySpecifiedTypes( + $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $condNarrowedScope)->getSpecifiedTypesForScope($condNarrowedScope, TypeSpecifierContext::createTruthy()), + ); $matchArmBody = new MatchExpressionArmBody($matchArmBodyScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); @@ -367,6 +287,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); + $armTypeResults[] = [$armResult, $matchArmBodyScope, $arm->body]; unset($arms[$i]); } @@ -393,6 +314,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex foreach ($arms as $i => $arm) { if ($arm->conds === null) { $hasDefaultCond = true; + $defaultArmBodyScope = $matchScope; $matchArmBody = new MatchExpressionArmBody($matchScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, [], $arm->getStartLine()); $armResult = $nodeScopeResolver->processExprNode($stmt, $arm->body, $matchScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); @@ -403,6 +325,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if (!$armResult->isAlwaysTerminating()) { $armBodyScopes[] = $matchScope; } + $armTypeResults[] = [$armResult, $defaultArmBodyScope, $arm->body]; continue; } @@ -411,10 +334,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $filteringExprs = []; + $filteringCondData = []; $armCondScope = $matchScope; $condNodes = []; $armCondResultScope = $matchScope; $bodyScope = null; + $condArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($expr->cond, $storage); foreach ($arm->conds as $j => $armCond) { if (isset($armCondsToSkip[$i][$j])) { continue; @@ -426,21 +351,70 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $armCondResult->getImpurePoints()); $armCondExpr = new BinaryOp\Identical($expr->cond, $armCond); $armCondResultScope = $armCondResult->getScope(); - $armCondType = $this->treatPhpDocTypesAsCertain ? $armCondResultScope->getType($armCondExpr) : $armCondResultScope->getNativeType($armCondExpr); + // the `subject === cond` verdict and both narrowing contexts, + // composed from the subject's THREADED per-arm state (carrying + // the previous arms' subtractions) and the condition's walk + // result - no synthetic Identical walk + $armSubjectType = $armCondResultScope->getStateType($expr->cond); + $armCondType = $this->treatPhpDocTypesAsCertain + ? $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope, $armCondExpr, $nodeScopeResolver, $armSubjectType, $armCondResult->getType())->type + : $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope->doNotTreatPhpDocTypesAsCertain(), $armCondExpr, $nodeScopeResolver, $armCondResultScope->doNotTreatPhpDocTypesAsCertain()->getStateType($expr->cond), $armCondResult->getNativeType())->type; if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } - $armCondScope = $armCondResultScope->filterByFalseyValue($armCondExpr); + $armCondArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($armCond, $storage); + $specifyArmCond = fn (TypeSpecifierContext $specifyContext): SpecifiedTypes => ($this->identicalNarrowingHelper->specifyIdentical( + $nodeScopeResolver, + $expr->cond, + $armCond, + $condResult, + $armCondResult, + $specifyContext, + $armCondResultScope, + $condArgResult, + $armCondArgResult, + fn (): Type => $this->richerScopeGetTypeHelper->getIdenticalResult($armCondResultScope, $armCondExpr, $nodeScopeResolver, $armCondResultScope->getStateType($expr->cond), $armCondResult->getType())->type, + ) ?? $this->defaultNarrowingHelper->specifyDefaultTypes($armCondExpr, $specifyContext))->setRootExpr($armCondExpr); + $armCondScope = $armCondResultScope->applySpecifiedTypes($specifyArmCond(TypeSpecifierContext::createFalsey())); + $armCondTruthyScope = $armCondResultScope->applySpecifiedTypes($specifyArmCond(TypeSpecifierContext::createTruthy())); if ($bodyScope === null) { - $bodyScope = $armCondResultScope->filterByTruthyValue($armCondExpr); + $bodyScope = $armCondTruthyScope; } else { - $bodyScope = $bodyScope->mergeWith($armCondResultScope->filterByTruthyValue($armCondExpr)); + $bodyScope = $bodyScope->mergeWith($armCondTruthyScope); } $filteringExprs[] = $armCond; + $filteringCondData[] = [$armCond, $armCondResult]; } - $filteringExpr = $this->getFilteringExprForMatchArm($expr, $filteringExprs); - $bodyScope ??= $matchScope->filterByTruthyValue($filteringExpr); + if (count($filteringCondData) === 1) { + // single-condition arm: the filtering expression is the same + // subject === cond comparison - compose its verdict from the + // walk results instead of pricing a synthetic node ($bodyScope + // is always set here, so the multi-cond branch's ??= has no + // single-cond counterpart) + if ($bodyScope === null) { + throw new ShouldNotHappenException(); + } + [$filteringCond, $filteringCondResult] = $filteringCondData[0]; + $filteringIdentical = new BinaryOp\Identical($expr->cond, $filteringCond); + $filteringExprType = $this->richerScopeGetTypeHelper->getIdenticalResult($matchScope, $filteringIdentical, $nodeScopeResolver, $matchScope->getStateType($expr->cond), $filteringCondResult->getType())->type; + // the falsey narrowing stays a synthetic walk: the walk re-prices + // the subject on the arm-narrowed scope, and that progressive + // narrowing (each arm sees the subject minus the previous arms' + // values) is what lets the last arm decide exhaustiveness - + // composing from the original subject result loses it (bug-6064) + $filteringFalseyTypes = $nodeScopeResolver->processSyntheticOnDemand($filteringIdentical, $armCondScope)->getSpecifiedTypesForScope($armCondScope, TypeSpecifierContext::createFalsey()); + } else { + // multi-condition arms compose through in_array so the narrowing + // stays owned by the in_array type-specifying extension; arms + // whose conditions were all skipped keep the empty in_array + // (always false) + $filteringExpr = $this->getFilteringExprForMatchArm($expr, $filteringExprs); + $filteringExprResult = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $matchScope); + $bodyScope ??= $matchScope->applySpecifiedTypes($filteringExprResult->getSpecifiedTypesForScope($matchScope, TypeSpecifierContext::createTruthy())); + $filteringExprType = $filteringExprResult->getTypeOnScope($matchScope, false); + $filteringFalseyTypes = $nodeScopeResolver->processSyntheticOnDemand($filteringExpr, $armCondScope)->getSpecifiedTypesForScope($armCondScope, TypeSpecifierContext::createFalsey()); + } $matchArmBody = new MatchExpressionArmBody($bodyScope, $arm->body); $armNodes[$i] = new MatchExpressionArm($matchArmBody, $condNodes, $arm->getStartLine()); @@ -459,7 +433,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); - $matchScope = $armCondScope->filterByFalseyValue($filteringExpr); + // Mirror getArmScopesAndTypes: an arm whose filtering expression is + // always false is unreachable and does not contribute to the result + // type. + if (!$filteringExprType->isFalse()->yes()) { + $armTypeResults[] = [$armResult, $bodyScope, $arm->body]; + } + $matchScope = $armCondScope->applySpecifiedTypes($filteringFalseyTypes); } if (!$hasDefaultCond && !$hasAlwaysTrueCond && $condType->isBoolean()->yes() && $condType->isConstantScalarValue()->yes()) { @@ -473,7 +453,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isExhaustive = $hasDefaultCond || $hasAlwaysTrueCond; if (!$isExhaustive) { - $remainingType = $matchScope->getType($expr->cond); + // $matchScope is the subject narrowed by "no arm matched". The arm + // narrowing is tracked by the scope (getTypeOnScope's authoritative + // read); only an untracked subject needs reprocessing there. + $remainingType = $condResult->answersOnScope($matchScope, false) + ? $condResult->getTypeOnScope($matchScope, false) + : $nodeScopeResolver->processExprOnDemand($expr->cond, $matchScope, new ExpressionResultStorage())->getType(); if ($remainingType instanceof NeverType) { $isExhaustive = true; } @@ -504,6 +489,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->cond = $expr->cond->getExpr(); } + $this->capturedArmResults[spl_object_id($expr)] = $armTypeResults; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -512,6 +499,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + // Each arm body was already processed on the scope where the subject + // is narrowed to that arm's condition - those captured scopes are the + // evaluation points, so the result type is just the union of the arm + // body types, no re-walk of the arms needed. + typeCallback: static function (bool $nativeTypesPromoted) use ($armTypeResults): Type { + // the union keeps void in the arm bodies (the raw type); + // ExpressionResult projects void->null for value reads and + // getKeepVoidType() keeps it, so UsageOfVoidMatchExpressionRule + // still sees a void arm + $types = []; + foreach ($armTypeResults as [$armResult]) { + $types[] = $armResult->getKeepVoidType($nativeTypesPromoted); + } + + return TypeCombinator::union(...$types); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } @@ -558,14 +562,16 @@ private function isScopeConditionallyImpossible(MutatingScope $scope): bool // Check if any boolean variable's both truth values lead to contradictions foreach ($boolVars as $varName) { $varExpr = new Variable($varName); + // a walked Variable's specify callback is exactly the default + // narrowing - no need to price the synthetic node on demand - $truthyScope = $scope->filterByTruthyValue($varExpr); + $truthyScope = $scope->applySpecifiedTypes($this->defaultNarrowingHelper->specifyDefaultTypes($varExpr, TypeSpecifierContext::createTruthy())); $truthyContradiction = $this->scopeHasNeverVariable($truthyScope, $boolVars); if (!$truthyContradiction) { continue; } - $falseyScope = $scope->filterByFalseyValue($varExpr); + $falseyScope = $scope->applySpecifiedTypes($this->defaultNarrowingHelper->specifyDefaultTypes($varExpr, TypeSpecifierContext::createFalsey())); $falseyContradiction = $this->scopeHasNeverVariable($falseyScope, $boolVars); if ($falseyContradiction) { return true; @@ -590,9 +596,4 @@ private function scopeHasNeverVariable(MutatingScope $scope, array $varNames): b return false; } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 16350c525c1..0288db3cd9e 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -4,7 +4,6 @@ use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\BooleanAnd; -use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\Ternary; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; @@ -12,29 +11,48 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\BooleanNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\NoopNodeCallback; -use PHPStan\Analyser\Scope; +use PHPStan\Analyser\PerFileAnalysisResettable; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Type\BooleanType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_merge; +use function spl_object_id; /** * @implements ExprHandler */ #[AutowiredService] -final class TernaryHandler implements ExprHandler +final class TernaryHandler implements ExprHandler, PerFileAnalysisResettable { + /** + * Keyed by the ternary node's spl_object_id(). The keys are AST nodes that + * live for the whole file's analysis (the parser cache retains them), so + * ids of live entries never collide; the per-file reset empties the map + * before another file could reuse them. + * + * @var array + */ + private array $capturedResults = []; + + public function resetFileAnalysisState(): void + { + $this->capturedResults = []; + } + public function __construct( - private NodeScopeResolver $nodeScopeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -44,60 +62,15 @@ public function supports(Expr $expr): bool return $expr instanceof Ternary; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $condResult = $this->nodeScopeResolver->processExprNode(new Stmt\Expression($expr->cond), $expr->cond, $scope, new ExpressionResultStorage(), new NoopNodeCallback(), ExpressionContext::createDeep()); - if ($expr->if === null) { - $conditionType = $scope->getType($expr->cond); - $booleanConditionType = $conditionType->toBoolean(); - if ($booleanConditionType->isTrue()->yes()) { - return $condResult->getTruthyScope()->getType($expr->cond); - } - - if ($booleanConditionType->isFalse()->yes()) { - return $condResult->getFalseyScope()->getType($expr->else); - } - - return TypeCombinator::union( - TypeCombinator::removeFalsey($condResult->getTruthyScope()->getType($expr->cond)), - $condResult->getFalseyScope()->getType($expr->else), - ); - } - - $booleanConditionType = $scope->getType($expr->cond)->toBoolean(); - if ($booleanConditionType->isTrue()->yes()) { - return $condResult->getTruthyScope()->getType($expr->if); - } - - if ($booleanConditionType->isFalse()->yes()) { - return $condResult->getFalseyScope()->getType($expr->else); - } - - return TypeCombinator::union( - $condResult->getTruthyScope()->getType($expr->if), - $condResult->getFalseyScope()->getType($expr->else), - ); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes + /** + * The cond/if/else results captured during the walk, for the assign-time + * conditional holders - null for short ternaries and unwalked nodes. + * + * @return array{ExpressionResult, ExpressionResult, ExpressionResult}|null + */ + public function getCapturedResults(Ternary $expr): ?array { - if ($expr->cond instanceof Ternary || $context->null()) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - if ($expr->if !== null) { - $conditionExpr = new BooleanOr( - new BooleanAnd($expr->cond, $expr->if), - new BooleanAnd(new Expr\BooleanNot($expr->cond), $expr->else), - ); - } else { - $conditionExpr = new BooleanOr( - $expr->cond, - new BooleanAnd(new Expr\BooleanNot($expr->cond), $expr->else), - ); - } - - return $typeSpecifier->specifyTypesInCondition($scope, $conditionExpr, $context)->setRootExpr($expr); + return $this->capturedResults[spl_object_id($expr)] ?? null; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult @@ -109,7 +82,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifTrueScope = $ternaryCondResult->getTruthyScope(); $ifFalseScope = $ternaryCondResult->getFalseyScope(); $ifTrueType = null; + $ifResult = null; + $ifProcessingScope = $ifTrueScope; + $elseProcessingScope = $ifFalseScope; if ($expr->if === null) { $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -122,7 +98,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $ifResult->getImpurePoints()); $hasYield = $hasYield || $ifResult->hasYield(); $ifTrueScope = $ifResult->getScope(); - $ifTrueType = $ifResult->getType(); + $ifTrueType = $ifResult->getTypeOnScope($ifProcessingScope, false); $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -131,6 +107,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseScope = $elseResult->getScope(); } + if ($ifResult !== null) { + $this->capturedResults[spl_object_id($expr)] = [$ternaryCondResult, $ifResult, $elseResult]; + } + $condType = $ternaryCondResult->getType(); if ($condType->isTrue()->yes()) { $finalScope = $ifTrueScope; @@ -140,7 +120,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($ifTrueType instanceof NeverType && $ifTrueType->isExplicit()) { $finalScope = $ifFalseScope; } else { - $ifFalseType = $elseResult->getType(); + $ifFalseType = $elseResult->getTypeOnScope($elseProcessingScope, false); if ($ifFalseType instanceof NeverType && $ifFalseType->isExplicit()) { $finalScope = $ifTrueScope; @@ -150,6 +130,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } + // lazily memoized merged-falsey scope of the (cond && if) disjunct + $aFalseyScope = null; + return $this->expressionResultFactory->create( $finalScope, beforeScope: $scope, @@ -158,6 +141,181 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $ternaryCondResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, + // the branches were processed on the cond-truthy/cond-falsey scopes + // including the condition's side effects - those captured scopes + // are the evaluation points, no re-walk needed. Reading the branch + // results ON those scopes matters when processExprNode answered a + // branch from a stored result (an on-demand ternary whose branches + // are already-walked real nodes): the stored walk-position type + // predates the condition's narrowing the branch scope carries. + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $elseProcessingScope, $nodeScopeResolver): Type { + if ($nativeTypesPromoted) { + $ifProcessingScope = $ifProcessingScope->doNotTreatPhpDocTypesAsCertain(); + } + $booleanConditionType = ($nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); + $elseType = $elseResult->getTypeOnScope($elseProcessingScope, $nativeTypesPromoted); + if ($expr->if === null || $ifResult === null) { + // short-ternary truthy value: the condition read on its own truthy + // scope. The truthy narrowing is tracked by the scope + // (getTypeOnScope's authoritative read); only an untracked + // condition needs reprocessing there. + $condTruthyType = $ternaryCondResult->answersOnScope($ifProcessingScope, false) + ? $ternaryCondResult->getTypeOnScope($ifProcessingScope, false) + : $nodeScopeResolver->processExprOnDemand($expr->cond, $ifProcessingScope, new ExpressionResultStorage())->getType(); + if ($booleanConditionType->isTrue()->yes()) { + return $condTruthyType; + } + + if ($booleanConditionType->isFalse()->yes()) { + return $elseType; + } + + return TypeCombinator::union( + TypeCombinator::removeFalsey($condTruthyType), + $elseType, + ); + } + + $ifType = $ifResult->getTypeOnScope($ifProcessingScope, $nativeTypesPromoted); + if ($booleanConditionType->isTrue()->yes()) { + return $ifType; + } + + if ($booleanConditionType->isFalse()->yes()) { + return $elseType; + } + + return TypeCombinator::union( + $ifType, + $elseType, + ); + }, + specifyTypesCallback: function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $elseProcessingScope, $nodeScopeResolver, $scope, &$aFalseyScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + if ($expr->cond instanceof Ternary || $context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + // cond ? if : else narrows like (cond && if) || (!cond && else), + // composed from the walk's results through the boolean helpers - + // the fabricated nodes are only printed into holder keys + $notCondNode = new Expr\BooleanNot($expr->cond); + + $condTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ternaryCondResult->getSpecifiedTypesForScope($scope, $ctx); + $condType = static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType(); + $notCondTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ternaryCondResult->getSpecifiedTypesForScope($scope, $ctx->negate()); + $notCondType = static function (bool $nativeTypesPromoted) use ($ternaryCondResult): Type { + $bool = ($nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); + if ($bool->isTrue()->yes()) { + return new ConstantBooleanType(false); + } + if ($bool->isFalse()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + $andVerdict = static fn (callable $left, callable $right): callable => static function (bool $nativeTypesPromoted) use ($left, $right): Type { + $leftBool = $left($nativeTypesPromoted)->toBoolean(); + $rightBool = $right($nativeTypesPromoted)->toBoolean(); + if ($leftBool->isFalse()->yes() || $rightBool->isFalse()->yes()) { + return new ConstantBooleanType(false); + } + if ($leftBool->isTrue()->yes() && $rightBool->isTrue()->yes()) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }; + $elseTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $elseResult->getSpecifiedTypesForScope($scope, $ctx); + $elseType = static fn (bool $nativeTypesPromoted): Type => $elseResult->getTypeOnScope($elseProcessingScope, $nativeTypesPromoted); + + // the decomposition's branch scopes are the operand walks' own + // memoized branch scopes (the evaluation points), not ask-derived; + // thunked so deep chains do not derive every level eagerly + $condTruthyScope = static fn (): MutatingScope => $ternaryCondResult->getTruthyScope(); + $condFalseyScope = static fn (): MutatingScope => $ternaryCondResult->getFalseyScope(); + + // right disjunct: !cond && else + $bNode = new BooleanAnd($notCondNode, $expr->else); + $elseFalseyOnCondFalseyScope = static fn (): MutatingScope => $elseResult->getFalseyScope(); + $bTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $scope, + $ctx, + $bNode, + $notCondNode, + $notCondTypes, + $condFalseyScope, + $condTruthyScope, + $expr->else, + $elseTypes, + $elseFalseyOnCondFalseyScope, + ); + $bType = $andVerdict($notCondType, $elseType); + $bTruthyScope = static fn (): MutatingScope => $elseResult->getTruthyScope(); + + if ($ifResult !== null && $expr->if !== null) { + // left disjunct: cond && if + $aNode = new BooleanAnd($expr->cond, $expr->if); + $ifTypes = static fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $ifResult->getSpecifiedTypesForScope($scope, $ctx); + $ifType = static fn (bool $nativeTypesPromoted): Type => $ifResult->getTypeOnScope($ifProcessingScope, $nativeTypesPromoted); + $ifFalseyOnCondTruthyScope = static fn (): MutatingScope => $ifResult->getFalseyScope(); + $aTypes = fn (MutatingScope $scope, TypeSpecifierContext $ctx): SpecifiedTypes => $this->booleanNarrowingHelper->specifyConjunction( + $nodeScopeResolver, + $scope, + $ctx, + $aNode, + $expr->cond, + $condTypes, + $condTruthyScope, + $condFalseyScope, + $expr->if, + $ifTypes, + $ifFalseyOnCondTruthyScope, + ); + $aType = $andVerdict($condType, $ifType); + $aTruthyScope = static fn (): MutatingScope => $ifResult->getTruthyScope(); + // the merged falsey of (cond && if) has no single walk scope - + // derived from the evaluation point on first demand, reused across asks + $aFalseyScopeThunk = static function () use ($scope, $aTypes, &$aFalseyScope): MutatingScope { + return $aFalseyScope ??= $scope->applySpecifiedTypes($aTypes($scope, TypeSpecifierContext::createFalsey())); + }; + + return $this->booleanNarrowingHelper->specifyDisjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $aNode, + $aTypes, + $aType, + $aTruthyScope, + $aFalseyScopeThunk, + $bNode, + $bTypes, + $bType, + $bTruthyScope, + )->setRootExpr($expr); + } + + // short ternary: cond || (!cond && else) + return $this->booleanNarrowingHelper->specifyDisjunction( + $nodeScopeResolver, + $s, + $context, + $expr, + $expr->cond, + $condTypes, + $condType, + $condTruthyScope, + $condFalseyScope, + $bNode, + $bTypes, + $bType, + $bTruthyScope, + )->setRootExpr($expr); + }, ); } diff --git a/tests/PHPStan/Rules/Exceptions/Bug14396Test.php b/tests/PHPStan/Rules/Exceptions/Bug14396Test.php new file mode 100644 index 00000000000..bb0e9c982ed --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/Bug14396Test.php @@ -0,0 +1,46 @@ + + */ +class Bug14396Test extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new MissingCheckedExceptionInFunctionThrowsRule( + new MissingCheckedExceptionInThrowsCheck(new DefaultExceptionTypeResolver( + self::createReflectionProvider(), + [], + [], + [], + [], + )), + ); + } + + protected function shouldTreatPhpDocTypesAsCertain(): bool + { + return false; + } + + #[RequiresPhp('>= 8.1')] + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/bug-14396.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/bug-14396.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Exceptions/bug-14396.neon b/tests/PHPStan/Rules/Exceptions/bug-14396.neon new file mode 100644 index 00000000000..feb290057aa --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/bug-14396.neon @@ -0,0 +1,5 @@ +parameters: + treatPhpDocTypesAsCertain: false + exceptions: + check: + missingCheckedExceptionInThrows: true diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-14396.php b/tests/PHPStan/Rules/Exceptions/data/bug-14396.php new file mode 100644 index 00000000000..bf8901f8127 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-14396.php @@ -0,0 +1,45 @@ += 8.1 + +declare(strict_types=1); + +namespace Bug14396; + +enum Status { + case A; + case B; + case C; +} + +class Item { + public function __construct( + public ?Status $status + ) {} +} + +/** +* @param list $list +*/ +function countAFromCollection(array $list): int +{ + $count = 0; + + foreach ($list as $item) { + match ($item->status) { + Status::A => ++$count, + Status::B, + Status::C, + null => null, + }; + } + + return $count; +} + +function countAFromItem(Item $item): ?int { + return match ($item->status) { + Status::A => 1, + Status::B, + Status::C, + null => null, + }; +} From 017be6df98e8b4f305b9059432f49619e4ace8c0 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:36 +0200 Subject: [PATCH 15/32] Build closure types eagerly from the single body walk ClosureHandler and ArrowFunctionHandler build the closure type (both flavours) from the body walk the handler already performs and pass it eagerly - a lazy typeCallback would re-walk the body on every ask. ClosureTypeResolver keeps the resolved types in a per-file spl_object_id map instead of a node attribute (attributes would leak onto the parser cache's retained ASTs), keys closure scope caches by the closure's free variables, and exposes getClosureType() for scope entry without a body re-walk. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- .../ExprHandler/ArrowFunctionHandler.php | 49 +++-- src/Analyser/ExprHandler/ClosureHandler.php | 46 +++-- .../Helper/ClosureTypeResolver.php | 178 +++++------------- tests/PHPStan/Analyser/nsrt/bug-11953.php | 23 +++ .../ArrowFunctionReturnTypeRuleTest.php | 2 +- .../CallToFunctionParametersRuleTest.php | 15 ++ .../Rules/Functions/data/bug-14914-arrow.php | 15 -- .../Rules/Functions/data/bug-14914.php | 10 + 8 files changed, 157 insertions(+), 181 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-11953.php delete mode 100644 tests/PHPStan/Rules/Functions/data/bug-14914-arrow.php diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index 564d7e90cab..570db0e1699 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -11,14 +11,11 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -30,6 +27,7 @@ final class ArrowFunctionHandler implements ExprHandler public function __construct( private ClosureTypeResolver $closureTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -42,9 +40,36 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); - $this->closureTypeResolver->seedCacheFromArrowFunctionWalk($scope, $expr, $arrowFunctionResult, $storage); $result = $arrowFunctionResult->getExpressionResult(); + // A plain typeCallback recursing through getClosureType() would re-walk + // the body each getType() ask before the cache populates and hang; + // ExpressionResult excludes closures from its tracked-type early return. + // Compute the ClosureType once here and store it as an eager value. + // + // Both flavours are built from the arrow function body the single walk in + // processArrowFunctionNode() already covered, without a second walk: the + // native flavour reads the body expression's stored native types off the + // same arrowScope (an arrow's native return type is its body's native type). + $arrowScope = $arrowFunctionResult->getArrowFunctionScope(); + $type = $this->closureTypeResolver->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ); + $nativeType = $this->closureTypeResolver->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + native: true, + ); + return $this->expressionResultFactory->create( $result->getScope(), beforeScope: $scope, @@ -53,17 +78,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + specifyTypesCallback: fn (TypeSpecifierContext $c, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), + type: $type, + nativeType: $nativeType, + typeCallback: null, ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->closureTypeResolver->getClosureType($scope, $expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index 683ebfc8859..f5a02400211 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -11,14 +11,11 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; -use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\Type; /** * @implements ExprHandler @@ -30,6 +27,7 @@ final class ClosureHandler implements ExprHandler public function __construct( private ClosureTypeResolver $closureTypeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -42,7 +40,31 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); - $this->closureTypeResolver->seedCacheFromClosureWalk($scope, $expr, $processClosureResult, $storage); + + // A plain typeCallback recursing through getClosureType() would re-walk + // the body each getType() ask before the cache populates and hang; + // ExpressionResult excludes closures from its tracked-type early return. + // Compute the ClosureType once here and store it as an eager value. + // + // The phpdoc flavour is built from the returns/yields the single body walk + // in processClosureNode() already gathered, without a second walk. + // + // A closure carries no @param/@return of its own, and its native type + // resolves the body the same way its phpdoc type does (a closure's native + // type equals its phpdoc type - e.g. a closure returning a positive-int + // method is Closure(): int<1, max> in both flavours). So the native + // flavour reuses the phpdoc ClosureType - no native walk. + $type = $this->closureTypeResolver->buildClosureTypeForClosure( + $scope, + $expr, + $processClosureResult->getGatheredReturnStatements(), + $processClosureResult->getGatheredYieldStatements(), + $processClosureResult->getExecutionEnds(), + $processClosureResult->getThrowPoints(), + $processClosureResult->getClosureTypeImpurePoints(), + $processClosureResult->getInvalidateExpressions(), + ); + $nativeType = $type; return $this->expressionResultFactory->create( $processClosureResult->applyByRefUseScope($processClosureResult->getScope()), @@ -52,17 +74,11 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + specifyTypesCallback: fn (TypeSpecifierContext $c, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $c), + type: $type, + nativeType: $nativeType, + typeCallback: null, ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $this->closureTypeResolver->getClosureType($scope, $expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index fc84e64a91f..ba2cb08c880 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -16,8 +16,6 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\PerFileAnalysisResettable; -use PHPStan\Analyser\ProcessArrowFunctionResult; -use PHPStan\Analyser\ProcessClosureResult; use PHPStan\Analyser\Scope; use PHPStan\Analyser\StatementContext; use PHPStan\Analyser\ThrowPoint; @@ -72,10 +70,8 @@ final class ClosureTypeResolver implements PerFileAnalysisResettable * file's analysis ends - the per-file reset releases the Types and * throw/impure points with the rest of the file's result graph. * - * Keyed by the closure node's spl_object_id(). The keys are AST nodes that - * live for the whole file's analysis (the parser cache retains them), so - * ids of live entries never collide; the per-file reset empties the map - * before another file could reuse them. + * Keyed by the closure node's spl_object_id() - see + * TernaryHandler::$capturedResults for the lifetime/collision reasoning. * * @var array> */ @@ -95,14 +91,15 @@ public function resetFileAnalysisState(): void /** * Resolves a closure/arrow function type by walking its body itself. Used by - * the paths that have no prior walk to read return/yield types from. A - * self-by-ref closure legitimately re-walks here (the - * $resolveClosureTypeDepth guard answers that ask). + * the paths that have NO prior walk to read return/yield types from - a + * closure asking its own type before its result is stored, and + * resolveCallableTypeForScope(). A self-by-ref closure legitimately re-walks + * here (the $resolveClosureTypeDepth guard answers that ask). * - * Callers that have already walked the body feed the gathered - * returns/yields to buildClosureTypeForClosure()/ - * buildClosureTypeForArrowFunction() instead, which construct the same - * ClosureType without a second walk. + * Callers that have already walked the body (the closure/arrow handlers and + * the closure-as-call-arg store sites) feed the gathered returns/yields to + * buildClosureType() instead, which constructs the same ClosureType without + * a second walk. */ public function getClosureType( MutatingScope $scope, @@ -117,7 +114,7 @@ public function getClosureType( // ENTRY (enterAnonymousFunction()/enterArrowFunction()) so entering a // closure/arrow scope never re-walks the body - the refined return type is // built afterwards from the single body walk's gathered returns and carried - // on the node/rule scope (see NodeScopeResolver::processClosureNode() + // on the node/rule scope (see NodeScopeResolver::processClosureNodeInternal() // and processArrowFunctionNode()). if ($shallow) { return new ClosureType( @@ -284,7 +281,6 @@ public function buildClosureTypeForClosure( array $impurePoints, array $invalidateExpressions, bool $native = false, - bool $writeCache = true, ): ClosureType { if ($this->bodyWalkHasOwnParameterTypes($expr)) { @@ -314,7 +310,6 @@ public function buildClosureTypeForClosure( $parameters, ), $native, - $writeCache, ); } @@ -336,7 +331,6 @@ public function buildClosureTypeForArrowFunction( array $impurePoints, array $invalidateExpressions, bool $native = false, - bool $writeCache = true, ): ClosureType { if ($this->bodyWalkHasOwnParameterTypes($expr)) { @@ -355,97 +349,32 @@ public function buildClosureTypeForArrowFunction( $expr, $native ? $nativeCallableParameters : $callableParameters, $parameters, - ), $writeCache); + )); } /** - * Seeds the per-node closure type cache from the single body walk the - * engine just performed (see ClosureHandler::processExpr()), so later - * getClosureType() asks answer from the walk instead of walking the - * body again. - * - * A closure's native type equals its phpdoc type - the native-flavour - * slot is seeded with the same build, keyed exactly as the - * promoted-scope ask computes its key. + * Whether getClosureType() would walk the body with different parameter types + * than NodeScopeResolver's single walk (processClosureNode()/ + * processArrowFunctionNode()) did. array_map() callbacks and immediately + * invoked closures get their parameter types from the array element type / + * the invocation arguments in getClosureType(), whereas the single walk types + * them from the closure's passed-to callable type - so the return type read + * from the gathered scopes would differ, and getClosureType() must re-walk. */ - public function seedCacheFromClosureWalk(MutatingScope $scope, Node\Expr\Closure $expr, ProcessClosureResult $processClosureResult, ExpressionResultStorage $storage): void - { - // a parked fiber may still append to the walk's gathered data - the - // invalidate expressions of a write like $this->prop[] = ... arrive - // only when the fiber flushes (see the invalidate-expressions note in - // NodeScopeResolver::processArgs()). Seed only when nothing is parked, - // so a seeded entry is never incomplete; otherwise the lazy ask keeps - // re-walking with the fibers flushed, as before. - if ($storage->pendingFibers !== []) { - return; - } - - // for array_map() callbacks and immediately invoked closures this - // delegates to a getClosureType() walk with the call-site parameter - // types; either way the phpdoc build lands in the cache under the - // key the plain ask computes - $this->buildClosureTypeForClosure( - $scope, - $expr, - $processClosureResult->getGatheredReturnStatements(), - $processClosureResult->getGatheredYieldStatements(), - $processClosureResult->getExecutionEnds(), - $processClosureResult->getThrowPoints(), - $processClosureResult->getClosureTypeImpurePoints(), - $processClosureResult->getInvalidateExpressions(), - ); - - [$parameters, , $callableParameters] = $this->buildParametersAndAcceptors($scope, $expr); - $phpdocKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters); - $cachedTypes = $this->cachedTypes[spl_object_id($expr)] ?? []; - if (!array_key_exists($phpdocKey, $cachedTypes)) { - return; - } - - $promotedScope = $scope->doNotTreatPhpDocTypesAsCertain(); - [$promotedParameters, , $promotedCallableParameters] = $this->buildParametersAndAcceptors($promotedScope, $expr); - $cachedTypes[$this->closureContextCacheKey($promotedScope, $expr, $promotedCallableParameters, $promotedParameters)] = $cachedTypes[$phpdocKey]; - $this->cachedTypes[spl_object_id($expr)] = $cachedTypes; - } - /** - * Arrow-function counterpart of seedCacheFromClosureWalk() (see - * ArrowFunctionHandler::processExpr()). Unlike a closure, an arrow - * function's native return type genuinely differs from its phpdoc one, - * so the native-flavour slot is seeded with its own build reading the - * walked body's native types. + * The expression roots this closure's type can read from the enclosing + * scope: '$this' and the use()d variables for closures, '$this' and + * every body variable that is not a parameter for arrow functions. Null + * when the body accesses variables dynamically ($$name, compact(), + * get_defined_vars()) and the whole scope must key the cache. + * + * @return list|null */ - public function seedCacheFromArrowFunctionWalk(MutatingScope $scope, ArrowFunction $expr, ProcessArrowFunctionResult $arrowFunctionResult, ExpressionResultStorage $storage): void - { - // see the parked-fiber note in seedCacheFromClosureWalk() - if ($storage->pendingFibers !== []) { - return; - } - - $this->buildClosureTypeForArrowFunction( - $scope, - $expr, - $arrowFunctionResult->getArrowFunctionScope(), - $arrowFunctionResult->getClosureTypeThrowPoints(), - $arrowFunctionResult->getClosureTypeImpurePoints(), - $arrowFunctionResult->getInvalidateExpressions(), - ); - $this->buildClosureTypeForArrowFunction( - $scope, - $expr, - $arrowFunctionResult->getArrowFunctionScope(), - $arrowFunctionResult->getClosureTypeThrowPoints(), - $arrowFunctionResult->getClosureTypeImpurePoints(), - $arrowFunctionResult->getInvalidateExpressions(), - true, - ); - } - /** * The cache key of everything this closure's type can depend on: the - * enclosing scope plus the parameter types the caller feeds in - - * array_map style callers type the same closure node per element - * through the callable parameters. + * free-variable slice of the scope plus the parameter types the caller + * feeds in - array_map style callers type the same closure node per + * element through the callable parameters. * * @param array|null $callableParameters * @param array $parameters @@ -457,21 +386,13 @@ private function closureContextCacheKey(MutatingScope $scope, Node\Expr\Closure| $parts[] = $parameter->getType()->describe(VerbosityLevel::cache()); } - // only arrow functions get a flavour-separated slot: their native return - // type genuinely differs from the phpdoc one. A closure's two flavours are - // distinguished by the scope hash alone - and deliberately share values - // when the seeding pin copies the phpdoc build to the promoted key - $flavour = $expr instanceof ArrowFunction ? ($scope->nativeTypesPromoted ? '/native' : '/phpdoc') : ''; - - return $scope->getClosureScopeCacheKey($this->freeVariableRoots($expr)) . '/' . implode('|', $parts) . $flavour; + return $scope->getClosureScopeCacheKey($this->freeVariableRoots($expr)) . '/' . implode('|', $parts) . ($scope->nativeTypesPromoted ? '/native' : '/phpdoc'); } /** * The expression roots this closure's type can read from the enclosing - * scope: '$this' and the use()d variables for closures, '$this' and - * every body variable that is not a parameter for arrow functions. Null - * when the body accesses variables dynamically ($$name, compact(), - * get_defined_vars()) and the whole scope must key the cache. + * scope - null when the body accesses variables dynamically and the + * whole scope must key the cache. * * @return list|null */ @@ -531,15 +452,6 @@ private function freeVariableRoots(Node\Expr\Closure|ArrowFunction $expr): ?arra return $rootList; } - /** - * Whether getClosureType() would walk the body with different parameter types - * than NodeScopeResolver's single walk (processClosureNode()/ - * processArrowFunctionNode()) did. array_map() callbacks and immediately - * invoked closures get their parameter types from the array element type / - * the invocation arguments in getClosureType(), whereas the single walk types - * them from the closure's passed-to callable type - so the return type read - * from the gathered scopes would differ, and getClosureType() must re-walk. - */ private function bodyWalkHasOwnParameterTypes(Node\Expr\Closure|ArrowFunction $expr): bool { return $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null @@ -568,7 +480,6 @@ private function buildClosureTypeFromClosureWalk( array $invalidateExpressions, ?string $cacheKey = null, bool $native = false, - bool $writeCache = true, ): ClosureType { $onlyNeverExecutionEnds = $this->deriveOnlyNeverExecutionEnds($executionEnds); @@ -673,7 +584,7 @@ private function buildClosureTypeFromClosureWalk( break; } - return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, $usedVariables, $cacheKey, $writeCache); + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, $usedVariables, $cacheKey); } private function resolveArrowFunctionReturnType( @@ -906,7 +817,6 @@ private function assembleClosureType( array $invalidateExpressions, array $usedVariables, ?string $cacheKey = null, - bool $writeCache = true, ): ClosureType { foreach ($parameters as $parameter) { @@ -926,18 +836,16 @@ private function assembleClosureType( $throwPointsForClosureType = array_map(static fn (ThrowPoint $throwPoint) => $throwPoint->isExplicit() ? SimpleThrowPoint::createExplicit($throwPoint->getType(), $throwPoint->canContainAnyThrowable()) : SimpleThrowPoint::createImplicit(), $throwPoints); $impurePointsForClosureType = array_map(static fn (ImpurePoint $impurePoint) => new SimpleImpurePoint($impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()), $impurePoints); - if ($writeCache) { - $cachedTypes = $this->cachedTypes[spl_object_id($expr)] ?? []; - $cacheKey ??= $this->closureContextCacheKey($scope, $expr, null, $parameters); - $cachedTypes[$cacheKey] = [ - 'returnType' => $returnType, - 'throwPoints' => $throwPointsForClosureType, - 'impurePoints' => $impurePointsForClosureType, - 'invalidateExpressions' => $invalidateExpressions, - 'usedVariables' => $usedVariables, - ]; - $this->cachedTypes[spl_object_id($expr)] = $cachedTypes; - } + $cachedTypes = $this->cachedTypes[spl_object_id($expr)] ?? []; + $cacheKey ??= $this->closureContextCacheKey($scope, $expr, null, $parameters); + $cachedTypes[$cacheKey] = [ + 'returnType' => $returnType, + 'throwPoints' => $throwPointsForClosureType, + 'impurePoints' => $impurePointsForClosureType, + 'invalidateExpressions' => $invalidateExpressions, + 'usedVariables' => $usedVariables, + ]; + $this->cachedTypes[spl_object_id($expr)] = $cachedTypes; $mustUseReturnValue = TrinaryLogic::createNo(); foreach ($expr->attrGroups as $attrGroup) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-11953.php b/tests/PHPStan/Analyser/nsrt/bug-11953.php new file mode 100644 index 00000000000..af3f138d743 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11953.php @@ -0,0 +1,23 @@ + $this->id, + $foo, + Foo::class, +); + +assertType('((Closure(): int)|null)', $closure); diff --git a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php index bca7ff21a7d..4ab72a3893c 100644 --- a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php @@ -79,7 +79,7 @@ public function testBugFunctionMethodConstants(): void public function testBug14914(): void { - $this->analyse([__DIR__ . '/data/bug-14914-arrow.php'], []); + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); } } diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index df3def0c39d..f8841954908 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -2934,6 +2934,14 @@ public function testConstantParameterCheck(): void 'Constant PREG_SPLIT_NO_EMPTY is not allowed for parameter #6 $flags of function preg_replace_callback.', 110, ], + [ + 'Parameter #2 $callback of function preg_replace_callback expects callable(array): string, Closure(mixed): array{non-falsy-string, int<-1, max>} given.', + 113, + ], + [ + 'Parameter #2 $callback of function preg_replace_callback expects callable(array): string, Closure(mixed): array{non-falsy-string|null, int<-1, max>} given.', + 116, + ], [ 'Constant PREG_SPLIT_NO_EMPTY is not allowed for parameter #5 $flags of function preg_replace_callback_array.', 119, @@ -3008,6 +3016,13 @@ public function testBug13643(): void $this->analyse([__DIR__ . '/data/bug-13643.php'], []); } + public function testBug13334(): void + { + $this->checkExplicitMixed = true; + $this->checkImplicitMixed = true; + $this->analyse([__DIR__ . '/data/bug-13334.php'], []); + } + public function testBug3842(): void { $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-3842.php'], []); diff --git a/tests/PHPStan/Rules/Functions/data/bug-14914-arrow.php b/tests/PHPStan/Rules/Functions/data/bug-14914-arrow.php deleted file mode 100644 index 868526b2faa..00000000000 --- a/tests/PHPStan/Rules/Functions/data/bug-14914-arrow.php +++ /dev/null @@ -1,15 +0,0 @@ -= 8.0 - -declare(strict_types = 1); - -namespace Bug14914Arrow; - -function doFoo(): void -{ - preg_replace_callback( - '/a|(?b)/', - fn (array $match) => $match['b'] !== null ? 'aa' : 'possible?', - 'abcd', - flags: PREG_UNMATCHED_AS_NULL, - ); -} diff --git a/tests/PHPStan/Rules/Functions/data/bug-14914.php b/tests/PHPStan/Rules/Functions/data/bug-14914.php index e6069300620..5bc6a7edb6a 100644 --- a/tests/PHPStan/Rules/Functions/data/bug-14914.php +++ b/tests/PHPStan/Rules/Functions/data/bug-14914.php @@ -18,3 +18,13 @@ function (array $match): string { flags: PREG_UNMATCHED_AS_NULL, ); } + +function doFoo2(): void +{ + preg_replace_callback( + '/a|(?b)/', + fn (array $match) => $match['b'] !== null ? 'aa' : 'possible?', + 'abcd', + flags: PREG_UNMATCHED_AS_NULL, + ); +} From b5261e3099184b9213bda8d8793bba3a57ff7da1 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:37 +0200 Subject: [PATCH 16/32] Thread assigned-value results through the assignment handlers prepareTarget()/applyWrite() carry the walked results of the target chain and the assigned value on PreparedAssignTarget, so the write path never re-prices what the walk already computed: chain-link results are stored read-flavoured for parked rule asks, conditional-holder sentinel comparisons go through specifyIdenticalAgainstType(), and ??= composes through CoalesceCompositionHelper without a synthetic Coalesce walk. The inc/dec handlers share the string/numeric type ladder in IncDecTypeHelper and hand an explicit value result to the virtual assign. PropertyReflectionFinder gains a variant taking the already-known holder type so offset writes do not re-read the receiver, and the ExistingArrayDimFetch links now reference the original, already-processed nodes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/ExprHandler/AssignHandler.php | 1054 +++++++++++------ src/Analyser/ExprHandler/AssignOpHandler.php | 242 ++-- .../ExprHandler/Helper/IncDecTypeHelper.php | 127 ++ src/Analyser/ExprHandler/PostDecHandler.php | 42 +- src/Analyser/ExprHandler/PostIncHandler.php | 42 +- src/Analyser/ExprHandler/PreDecHandler.php | 102 +- src/Analyser/ExprHandler/PreIncHandler.php | 103 +- src/Analyser/PreparedAssignTarget.php | 56 +- src/Node/Expr/ExistingArrayDimFetch.php | 6 + .../Properties/PropertyReflectionFinder.php | 30 + .../PHPStan/Analyser/nsrt/assign-in-array.php | 23 + tests/PHPStan/Analyser/nsrt/bug-12207.php | 31 + tests/PHPStan/Analyser/nsrt/bug-13944.php | 48 + tests/PHPStan/Analyser/nsrt/bug-14999.php | 17 + tests/PHPStan/Analyser/nsrt/bug-7155.php | 16 + .../indexed-assign-rhs-container-mutation.php | 41 + .../Rules/Variables/NullCoalesceRuleTest.php | 5 + .../Rules/Variables/data/bug-12780.php | 29 + 18 files changed, 1418 insertions(+), 596 deletions(-) create mode 100644 src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php create mode 100644 tests/PHPStan/Analyser/nsrt/assign-in-array.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-12207.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13944.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14999.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7155.php create mode 100644 tests/PHPStan/Analyser/nsrt/indexed-assign-rhs-container-mutation.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-12780.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index c0dade166a0..9f3a8d793a2 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser\ExprHandler; use ArrayAccess; +use Closure; use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Expr\ArrayDimFetch; @@ -29,8 +30,11 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; use PHPStan\Analyser\ExprHandler\Helper\NonNullabilityHelper; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -39,7 +43,6 @@ use PHPStan\Analyser\PreparedAssignTarget; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\ExistingArrayDimFetch; @@ -59,6 +62,7 @@ use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; +use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; @@ -86,6 +90,7 @@ use function in_array; use function is_int; use function is_string; +use function spl_object_id; /** * @implements ExprHandler @@ -95,12 +100,15 @@ final class AssignHandler implements ExprHandler { public function __construct( - private TypeSpecifier $typeSpecifier, private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, + private TernaryHandler $ternaryHandler, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, private PropertyReflectionFinder $propertyReflectionFinder, + private VirtualExprResultHelper $virtualExprResultHelper, private NonNullabilityHelper $nonNullabilityHelper, private VariableHandler $variableHandler, private ArrayDimFetchHandler $arrayDimFetchHandler, @@ -116,189 +124,6 @@ public function supports(Expr $expr): bool return $expr instanceof Assign || $expr instanceof AssignRef; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$expr instanceof Assign) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - if ($context->null()) { - $specifiedTypes = $typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->expr, $context)->setRootExpr($expr); - $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); - } else { - $specifiedTypes = $typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->var, $context)->setRootExpr($expr); - } - - // infer $arr[$key] after $key = array_key_first/last($arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) - && count($expr->expr->getArgs()) >= 1 - ) { - $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); - $isNonEmpty = true; - } else { - $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); - } - - if ($isNonEmpty) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $nonNullKeyType = TypeCombinator::removeNull($keyType); - if (!$nonNullKeyType instanceof NeverType) { - $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()), - ); - } - } - } - } - - // infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && count($expr->expr->getArgs()) >= 2 - ) { - $funcName = $expr->expr->name->toLowerString(); - $arrayArg = null; - $sentinelType = null; - $isStrictArraySearch = false; - - if ($funcName === 'array_search') { - $arrayArg = $expr->expr->getArgs()[1]->value; - $sentinelType = new ConstantBooleanType(false); - $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes(); - } elseif ($funcName === 'array_find_key') { - $arrayArg = $expr->expr->getArgs()[0]->value; - $sentinelType = new NullType(); - } - - if ($arrayArg !== null) { - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); - - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - if ($isStrictArraySearch) { - $needleType = $scope->getType($expr->expr->getArgs()[0]->value); - $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); - } else { - $dimFetchType = $arrayType->getIterableValueType(); - } - - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $dimFetchType, TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); - if (!$narrowedKeyType instanceof NeverType) { - if ($isStrictArraySearch) { - $needleType = $scope->getType($expr->expr->getArgs()[0]->value); - $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); - } else { - $dimFetchType = $arrayType->getIterableValueType(); - } - $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $narrowedKeyType, $dimFetchType), - ); - } - } - } - } - } - - if ($context->null()) { - // infer $arr[$key] after $key = array_rand($arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && in_array($expr->expr->name->toLowerString(), ['array_rand'], true) - && count($expr->expr->getArgs()) >= 1 - ) { - $numArg = null; - $args = $expr->expr->getArgs(); - $arrayArg = $args[0]->value; - if (count($args) > 1) { - $numArg = $args[1]->value; - } - $one = new ConstantIntegerType(1); - $arrayType = $scope->getType($arrayArg); - - if ( - $arrayType->isArray()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() - && ($numArg === null || $one->isSuperTypeOf($scope->getType($numArg))->yes()) - ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - return $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } - } - - // infer $list[$count] after $count = count($list) - 1 - if ( - $expr->expr instanceof Expr\BinaryOp\Minus - && $expr->expr->left instanceof FuncCall - && $expr->expr->left->name instanceof Name - && !$expr->expr->left->isFirstClassCallable() - && $expr->expr->right instanceof Node\Scalar\Int_ - && $expr->expr->right->value === 1 - && in_array($expr->expr->left->name->toLowerString(), ['count', 'sizeof'], true) - && count($expr->expr->left->getArgs()) >= 1 - ) { - $arrayArg = $expr->expr->left->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - if ( - $arrayType->isList()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() - ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - return $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } - } - - return $specifiedTypes; - } - - return $specifiedTypes; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; @@ -355,7 +180,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $result = $this->applyWrite( $nodeScopeResolver, $target, - $this->expressionResultFactory->create($valueScope, $valueBeforeScope, $expr->expr, $assignedExprResult->hasYield(), $assignedExprResult->isAlwaysTerminating(), $assignedExprResult->getThrowPoints(), $valueImpurePoints), + $this->expressionResultFactory->create( + $valueScope, + beforeScope: $valueBeforeScope, + expr: $expr->expr, + hasYield: $assignedExprResult->hasYield(), + isAlwaysTerminating: $assignedExprResult->isAlwaysTerminating(), + throwPoints: $assignedExprResult->getThrowPoints(), + impurePoints: $valueImpurePoints, + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $assignedExprResult->getNativeType() : $assignedExprResult->getType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ), + $assignedExprResult, $stmt, $storage, $nodeCallback, @@ -372,8 +208,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ) { $varName = $expr->var->name; $refName = $expr->expr->name; - $type = $scope->getType($expr->var); - $nativeType = $scope->getNativeType($expr->var); + // a plain variable read is scope state - no result or walk needed + $type = $scope->hasVariableType($varName)->no() ? new ErrorType() : $scope->getVariableType($varName); + $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); + $nativeType = $nativeScope->hasVariableType($varName)->no() ? new ErrorType() : $nativeScope->getVariableType($varName); // When $varName is assigned, update $refName $scope = $scope->assignExpression( @@ -407,9 +245,259 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $assignedExprResult->getNativeType() : $assignedExprResult->getType(), + specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($expr, $assignedExprResult, $beforeScope, $storage) : fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult, $beforeScope) : null, ); } + /** + * Results of a walked call's arguments (and of the count()-minus-one shape's + * call), keyed by the argument value expression - the sources the lazy + * assignment narrowing reads. + * + * @return array + */ + private function captureAssignedCallArgResults(Expr $assignedExpr, ExpressionResultStorage $storage): array + { + $call = null; + if ($assignedExpr instanceof FuncCall) { + $call = $assignedExpr; + } elseif ($assignedExpr instanceof Expr\BinaryOp\Minus && $assignedExpr->left instanceof FuncCall) { + $call = $assignedExpr->left; + } + if ($call === null || $call->isFirstClassCallable()) { + return []; + } + + $argResults = []; + foreach ($call->getArgs() as $arg) { + $argResult = $storage->findExpressionResult($arg->value); + if ($argResult === null) { + continue; + } + + $argResults[spl_object_id($arg->value)] = $argResult; + } + + return $argResults; + } + + /** + * A type constraint on an assignment constrains the assigned variable + * and the assigned expression - what TypeSpecifier::create() recovered + * by unwrapping assign chains. Nested assignments compose through the + * assigned expression's own result. + * + * @return Closure(Type, TypeSpecifierContext, bool): SpecifiedTypes + */ + private function createCreateTypesCallback(Assign $expr, ExpressionResult $assignedExprResult, MutatingScope $beforeScope): Closure + { + return function (Type $type, TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $assignedExprResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $types = $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->var, null, $type, $context); + + return $types->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->expr, $assignedExprResult, $type, $context), + ); + }; + } + + /** + * New-world copy of the non-null contexts of specifyTypes(): the assigned + * variable narrows by the boolean outcome, plus the $arr[$key] inference + * after $key = array_key_first/array_key_last/array_search/array_find_key. + * The null-context inferences stay in specifyTypes() - result-based asks + * are always truthy or falsey. + * + * @return Closure(TypeSpecifierContext, bool): SpecifiedTypes + */ + private function createSpecifyTypesCallback(Assign $expr, ExpressionResult $assignedExprResult, MutatingScope $beforeScope, ExpressionResultStorage $storage): Closure + { + // the value expression's call arguments were walked as its children - + // capture their results now so the lazy narrowing below reads them + // instead of the storage + $argResults = $this->captureAssignedCallArgResults($expr->expr, $storage); + + return function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $assignedExprResult, $beforeScope, $argResults): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + $argType = static function (Expr $e) use ($argResults, $s): Type { + $result = $argResults[spl_object_id($e)] ?? null; + if ($result !== null) { + return $result->getTypeOnScope($s, $s->nativeTypesPromoted); + } + + // every argument of the walked call has a captured result + throw new ShouldNotHappenException(); + }; + if ($context->null()) { + $assignedScope = $s->exitFirstLevelStatements(); + $specifiedTypes = $assignedExprResult->getSpecifiedTypesForScope($assignedScope, $context)->setRootExpr($expr); + $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); + } else { + $specifiedTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $context)->setRootExpr($expr); + } + + // infer $arr[$key] after $key = array_key_first/last($arr) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() + && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) + && count($expr->expr->getArgs()) >= 1 + ) { + $arrayArg = $expr->expr->getArgs()[0]->value; + $arrayType = $argType($arrayArg); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $arrayArg, null, new NonEmptyArrayType(), TypeSpecifierContext::createTrue()), + ); + $isNonEmpty = true; + } else { + $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); + } + + if ($isNonEmpty) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { + $keyType = $assignedExprResult->getTypeOnScope($s, $s->nativeTypesPromoted); + $nonNullKeyType = TypeCombinator::removeNull($keyType); + if (!$nonNullKeyType instanceof NeverType) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()), + ); + } + } + } + } + + // infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() + && count($expr->expr->getArgs()) >= 2 + ) { + $funcName = $expr->expr->name->toLowerString(); + $arrayArg = null; + $sentinelType = null; + $isStrictArraySearch = false; + + if ($funcName === 'array_search') { + $arrayArg = $expr->expr->getArgs()[1]->value; + $sentinelType = new ConstantBooleanType(false); + $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $argType($expr->expr->getArgs()[2]->value)->isTrue()->yes(); + } elseif ($funcName === 'array_find_key') { + $arrayArg = $expr->expr->getArgs()[0]->value; + $sentinelType = new NullType(); + } + + if ($arrayArg !== null) { + $arrayType = $argType($arrayArg); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $arrayArg, null, new NonEmptyArrayType(), TypeSpecifierContext::createTrue()), + ); + + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + if ($isStrictArraySearch) { + $needleType = $argType($expr->expr->getArgs()[0]->value); + $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); + } else { + $dimFetchType = $arrayType->getIterableValueType(); + } + + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $dimFetchType, TypeSpecifierContext::createTrue()), + ); + } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { + $keyType = $assignedExprResult->getTypeOnScope($s, $s->nativeTypesPromoted); + $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); + if (!$narrowedKeyType instanceof NeverType) { + if ($isStrictArraySearch) { + $needleType = $argType($expr->expr->getArgs()[0]->value); + $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); + } else { + $dimFetchType = $arrayType->getIterableValueType(); + } + $specifiedTypes = $specifiedTypes->unionWith( + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $narrowedKeyType, $dimFetchType), + ); + } + } + } + } + } + + if ($context->null()) { + // infer $arr[$key] after $key = array_rand($arr) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() + && in_array($expr->expr->name->toLowerString(), ['array_rand'], true) + && count($expr->expr->getArgs()) >= 1 + ) { + $numArg = null; + $args = $expr->expr->getArgs(); + $arrayArg = $args[0]->value; + if (count($args) > 1) { + $numArg = $args[1]->value; + } + $one = new ConstantIntegerType(1); + $arrayType = $argType($arrayArg); + + if ( + $arrayType->isArray()->yes() + && $arrayType->isIterableAtLeastOnce()->yes() + && ($numArg === null || $one->isSuperTypeOf($argType($numArg))->yes()) + ) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + return $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } + } + + // infer $list[$count] after $count = count($list) - 1 + if ( + $expr->expr instanceof Expr\BinaryOp\Minus + && $expr->expr->left instanceof FuncCall + && $expr->expr->left->name instanceof Name + && !$expr->expr->left->isFirstClassCallable() + && $expr->expr->right instanceof Node\Scalar\Int_ + && $expr->expr->right->value === 1 + && in_array($expr->expr->left->name->toLowerString(), ['count', 'sizeof'], true) + && count($expr->expr->left->getArgs()) >= 1 + ) { + $arrayArg = $expr->expr->left->getArgs()[0]->value; + $arrayType = $argType($arrayArg); + if ( + $arrayType->isList()->yes() + && $arrayType->isIterableAtLeastOnce()->yes() + ) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + return $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } + } + } + + return $specifiedTypes; + }; + } + /** * The pre-value half of an assignment: walks the target's sub-expressions * (root, dimensions, receiver, dynamic name) in PHP's evaluation order and @@ -433,16 +521,8 @@ public function prepareTarget( { $enterExpressionAssign = $mode->enterExpressionAssign(); $targetReadResult = null; + $targetChainResults = []; $beforeScope = $scope; - $nodeScopeResolver->storeExpressionResult($storage, $var, $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $var, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - )); $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope, $storage); $hasYield = false; $throwPoints = []; @@ -454,9 +534,9 @@ public function prepareTarget( if ($mode->producesTargetReadResult()) { // `$lvalue OP= ...` reads the old value of `$lvalue`; the write walk // processes a Variable target only as an assignment target, never as - // a read. The read is composed here without a walk - for ??= with - // isset() semantics (mirroring CoalesceHandler's left-side - // processing, with the isset descriptor - bug-13623). + // a read. The read result is composed here without a walk - the + // ??= read with isset() semantics (mirroring CoalesceHandler's + // left-side processing, with the isset descriptor - bug-13623). if (!is_string($var->name)) { // `$$name OP= ...` evaluates the name before reading the old // value: walk it once here, the write flow consumes the result @@ -472,7 +552,10 @@ public function prepareTarget( $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); } - $targetReadResult = $this->variableHandler->composeResult($var, $variableNameResult, $readScope); + $targetReadResult = $this->variableHandler->composeResult($nodeScopeResolver, $var, $variableNameResult, $storage, $readScope); + if ($mode->issetSemanticsForRead()) { + $targetChainResults[spl_object_id($var)] = $targetReadResult; + } } return new PreparedAssignTarget( @@ -488,6 +571,7 @@ public function prepareTarget( $impurePoints, $isAlwaysTerminating, targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, variableNameResult: $variableNameResult, ); } @@ -499,7 +583,7 @@ public function prepareTarget( while ($var instanceof ArrayDimFetch) { $varForSetOffsetValue = $var->var; if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($varForSetOffsetValue, $scope)); + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($nodeScopeResolver, $varForSetOffsetValue, $scope)); } if ( @@ -530,13 +614,12 @@ public function prepareTarget( if ($enterExpressionAssign) { $scope = $scope->enterExpressionAssign($var, false); } - $result = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $rootReadResult = $result; - $hasYield = $result->hasYield(); - $throwPoints = $result->getThrowPoints(); - $impurePoints = $result->getImpurePoints(); - $isAlwaysTerminating = $result->isAlwaysTerminating(); - $scope = $result->getScope(); + $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $hasYield = $varResult->hasYield(); + $throwPoints = $varResult->getThrowPoints(); + $impurePoints = $varResult->getImpurePoints(); + $isAlwaysTerminating = $varResult->isAlwaysTerminating(); + $scope = $varResult->getScope(); if ($enterExpressionAssign) { $scope = $scope->exitExpressionAssign($var); } @@ -545,8 +628,10 @@ public function prepareTarget( $offsetTypes = []; $offsetNativeTypes = []; $dimResults = []; + $deferredDimFetchResults = []; $dimFetchStack = array_reverse($dimFetchStack); $lastDimKey = array_key_last($dimFetchStack); + $previousLinkResult = $varResult; foreach ($dimFetchStack as $key => $dimFetch) { $dimExpr = $dimFetch->dim; @@ -556,10 +641,10 @@ public function prepareTarget( } if ($dimExpr === null) { - $dimResults[$key] = null; $offsetTypes[] = [null, $dimFetch]; $offsetNativeTypes[] = [null, $dimFetch]; - $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( + $dimResults[$key] = null; + $fabricatedResult = $this->expressionResultFactory->create( $scope, beforeScope: $scope, expr: $dimFetch, @@ -567,13 +652,27 @@ public function prepareTarget( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - )); + typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + $deferredDimFetchResults[] = [$dimFetch, $fabricatedResult]; + $previousLinkResult = $fabricatedResult; } else { if ($enterExpressionAssign) { $scope->enterExpressionAssign($dimExpr); } - $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( + // process the dimension first, then consume its ExpressionResult + // (single-pass inside-out) rather than reading it before processExprNode() + $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $dimResults[$key] = $result; + $offsetTypes[] = [$result->getType(), $dimFetch]; + $offsetNativeTypes[] = [$result->getNativeType(), $dimFetch]; + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + + $dimNodeResult = $result; + $fabricatedResult = $this->expressionResultFactory->create( $scope, beforeScope: $scope, expr: $dimFetch, @@ -581,13 +680,15 @@ public function prepareTarget( isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - )); - $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); - $dimResults[$key] = $result; - $offsetTypes[] = [$result->getType(), $dimFetch]; - $offsetNativeTypes[] = [$result->getNativeType(), $dimFetch]; - $hasYield = $hasYield || $result->hasYield(); - $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + typeCallback: static function (bool $nativeTypesPromoted) use ($previousLinkResult, $dimNodeResult, $scope): Type { + $s = $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + + return $previousLinkResult->getTypeOnScope($s, $s->nativeTypesPromoted)->getOffsetValueType($dimNodeResult->getTypeOnScope($s, $s->nativeTypesPromoted)); + }, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + $deferredDimFetchResults[] = [$dimFetch, $fabricatedResult]; + $previousLinkResult = $fabricatedResult; $scope = $result->getScope(); if ($enterExpressionAssign) { @@ -599,16 +700,40 @@ public function prepareTarget( if ($mode->issetSemanticsForRead()) { // `$lvalue ??= ...` reads the chain with isset() semantics. The root // and dimensions were just walked, so each chain link's read is - // composed from their results - no re-walk - and carries the isset - // descriptor (bug-13623). + // composed from their results - no re-walk. The reads carry the isset + // descriptor (bug-13623) and are stored, which is what parked rule + // asks observe; the write-flavoured results below then replace them + // in storage, exactly as before. $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $originalVar); $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $originalVar); - $levelReadResult = $rootReadResult; + $levelReadResult = $varResult; foreach ($dimFetchStack as $key => $dimFetch) { $levelReadResult = $this->arrayDimFetchHandler->composeResult($nodeScopeResolver, $stmt, $dimFetch, $dimResults[$key], $levelReadResult, $storage, $context, $readScope); + $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $levelReadResult); + $targetChainResults[spl_object_id($dimFetch)] = $levelReadResult; + if ($dimFetch->dim === null || $dimResults[$key] === null) { + continue; + } + + $targetChainResults[spl_object_id($dimFetch->dim)] = $dimResults[$key]; } $targetReadResult = $levelReadResult; + // the root (and, when it is itself a fetch chain, its links) was + // stored by its own walk above + $this->defaultNarrowingHelper->captureChainResults($var, $storage, $targetChainResults); + } elseif ($mode->producesTargetReadResult()) { + // `$lvalue OP= ...`: the value the target reads is the write-flavoured + // result of the whole chain, fabricated above + [, $targetReadResult] = $deferredDimFetchResults[count($deferredDimFetchResults) - 1]; + } + foreach ($deferredDimFetchResults as [$deferredDimFetch, $deferredResult]) { + $nodeScopeResolver->storeExpressionResult($storage, $deferredDimFetch, $deferredResult); } + // the chain link the write's ArrayAccess::offsetSet would be invoked on: + // the second-outermost link, or the root for a single-dimension target + $offsetSetTargetResult = count($deferredDimFetchResults) >= 2 + ? $deferredDimFetchResults[count($deferredDimFetchResults) - 2][1] + : $varResult; return new PreparedAssignTarget( PreparedAssignTarget::KIND_ARRAY_DIM_FETCH, @@ -623,11 +748,14 @@ public function prepareTarget( $impurePoints, $isAlwaysTerminating, rootVar: $var, + varResult: $varResult, dimFetchStack: $dimFetchStack, assignedPropertyExpr: $assignedPropertyExpr, offsetTypes: $offsetTypes, offsetNativeTypes: $offsetNativeTypes, + offsetSetTargetResult: $offsetSetTargetResult, targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, ); } @@ -653,13 +781,28 @@ public function prepareTarget( $scope = $propertyNameResult->getScope(); } + $scopeBeforeAssignEval = $scope; if ($mode->issetSemanticsForRead()) { // `$lvalue ??= ...` reads the property with isset() semantics: the // read is composed from the just-walked receiver and name results - - // no re-walk - and carries the isset descriptor (bug-13623). + // no re-walk - and carries the isset descriptor (bug-13623). Stored + // so parked rule asks observe the read flavour, exactly as they + // observed the former pre-read's store. $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); $targetReadResult = $this->propertyFetchHandler->composeResult($nodeScopeResolver, $var, $objectResult, $propertyNameResult, $scopeBeforeVar, $readScope); + $nodeScopeResolver->storeExpressionResult($storage, $var, $targetReadResult); + $this->defaultNarrowingHelper->captureChainResults($var, $storage, $targetChainResults); + } + // The raw target fetch was emitted to node callbacks at the top of + // prepareTarget() but the assign flow never processes it as a + // read. Compose and store it once here from the receiver's and + // name's results, so askers parked on it (DependencyResolver, + // property rules) resume with its pre-assign type. + $parkedReadResult = $this->propertyFetchHandler->composeResult($nodeScopeResolver, $var, $objectResult, $propertyNameResult, $scopeBeforeVar, $scopeBeforeAssignEval); + $nodeScopeResolver->storeExpressionResult($storage, $var, $parkedReadResult); + if ($mode->producesTargetReadResult() && !$mode->issetSemanticsForRead()) { + $targetReadResult = $parkedReadResult; } return new PreparedAssignTarget( @@ -674,8 +817,10 @@ public function prepareTarget( $throwPoints, $impurePoints, $isAlwaysTerminating, + objectResult: $objectResult, propertyName: $propertyName, targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, ); } @@ -685,7 +830,7 @@ public function prepareTarget( $propertyHolderType = $scope->resolveTypeByName($var->class); } else { $classResult = $nodeScopeResolver->processExprNode($stmt, $var->class, $scope, $storage, $nodeCallback, $context); - $propertyHolderType = $scope->getType($var->class); + $propertyHolderType = $classResult->getType(); } $propertyName = null; @@ -701,6 +846,7 @@ public function prepareTarget( $scope = $propertyNameResult->getScope(); } + $scopeBeforeAssignEval = $scope; if ($mode->issetSemanticsForRead()) { // Same as the PropertyFetch branch above: the ??= read is composed // from the just-walked class/name results on the isset-semantics @@ -708,6 +854,15 @@ public function prepareTarget( $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); $targetReadResult = $this->staticPropertyFetchHandler->composeResult($var, $classResult, $propertyNameResult, $readScope); + $nodeScopeResolver->storeExpressionResult($storage, $var, $targetReadResult); + $this->defaultNarrowingHelper->captureChainResults($var, $storage, $targetChainResults); + } + // Same as the PropertyFetch branch above: the emitted target fetch + // needs a stored result for parked askers. + $parkedReadResult = $this->staticPropertyFetchHandler->composeResult($var, $classResult, $propertyNameResult, $scopeBeforeAssignEval); + $nodeScopeResolver->storeExpressionResult($storage, $var, $parkedReadResult); + if ($mode->producesTargetReadResult() && !$mode->issetSemanticsForRead()) { + $targetReadResult = $parkedReadResult; } return new PreparedAssignTarget( @@ -725,6 +880,7 @@ public function prepareTarget( propertyName: $propertyName, propertyHolderType: $propertyHolderType, targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, ); } @@ -751,7 +907,7 @@ public function prepareTarget( while ($var instanceof ExistingArrayDimFetch) { $varForSetOffsetValue = $var->getVar(); if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($varForSetOffsetValue, $scope)); + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($nodeScopeResolver, $varForSetOffsetValue, $scope)); } $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( $varForSetOffsetValue, @@ -762,15 +918,16 @@ public function prepareTarget( $var = $var->getVar(); } - // the chain is a clone of AST nodes already processed elsewhere (see - // Unset_ handling) - the types below price the clones directly, no - // walk is needed + // the chain links reference the original, already-processed AST nodes + // (see the Unset_ handling) - read their stored results, no walk + $varResult = $nodeScopeResolver->readStoredResult($var, $storage); + $offsetTypes = []; $offsetNativeTypes = []; foreach (array_reverse($dimFetchStack) as $dimFetch) { - $dimExpr = $dimFetch->getDim(); - $offsetTypes[] = [$scope->getType($dimExpr), $dimFetch]; - $offsetNativeTypes[] = [$scope->getNativeType($dimExpr), $dimFetch]; + $dimResult = $nodeScopeResolver->readStoredResult($dimFetch->getDim(), $storage); + $offsetTypes[] = [$dimResult->getType(), $dimFetch]; + $offsetNativeTypes[] = [$dimResult->getNativeType(), $dimFetch]; } return new PreparedAssignTarget( @@ -786,23 +943,28 @@ public function prepareTarget( $impurePoints, $isAlwaysTerminating, rootVar: $var, + varResult: $varResult, assignedPropertyExpr: $assignedPropertyExpr, existingOffsetTypes: $offsetTypes, existingOffsetNativeTypes: $offsetNativeTypes, ); } - $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context); - $hasYield = $varResult->hasYield(); - $throwPoints = array_merge($throwPoints, $varResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); - $isAlwaysTerminating = $varResult->isAlwaysTerminating(); - $scope = $varResult->getScope(); + $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context); + $hasYield = $varResult->hasYield(); + $throwPoints = array_merge($throwPoints, $varResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); + $isAlwaysTerminating = $varResult->isAlwaysTerminating(); + $scope = $varResult->getScope(); if ($mode->producesTargetReadResult()) { - // a synthetic op=/??= target: the walk above already priced the target - // as a read - its result is the read + // a synthetic op=/??= target (e.g. InvalidBinaryOperationRule's + // TypeExpr-operand clone priced on demand): the walk above already + // priced the target as a read - its result is the read $targetReadResult = $varResult; + if ($mode->issetSemanticsForRead()) { + $targetChainResults[spl_object_id($var)] = $varResult; + } } return new PreparedAssignTarget( @@ -818,6 +980,7 @@ public function prepareTarget( $impurePoints, $isAlwaysTerminating, targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, ); } @@ -825,7 +988,10 @@ public function prepareTarget( * The post-value half of an assignment: performs the write and its * bookkeeping (narrowing, conditional expressions, node callbacks) for a * target walked by prepareTarget(), consuming the caller-processed value - * result. + * result. $valueResult carries the value evaluation's scope and points; + * $assignedValueResult is the result standing for the assigned expression + * itself (the value to write) - null lets the reads fall back to stored + * results or on-demand pricing. * * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -833,6 +999,7 @@ public function applyWrite( NodeScopeResolver $nodeScopeResolver, PreparedAssignTarget $target, ExpressionResult $valueResult, + ?ExpressionResult $assignedValueResult, Node\Stmt $stmt, ExpressionResultStorage $storage, callable $nodeCallback, @@ -866,40 +1033,66 @@ public function applyWrite( $impurePoints[] = new ImpurePoint($scopeBeforeAssignEval, $var, 'superglobal', 'assign to superglobal variable', true); } $assignedExpr = $this->unwrapAssign($assignedExpr); - $type = $scopeBeforeAssignEval->getType($assignedExpr); + // the caller-passed value result; a nested assign chain's value is the + // innermost assigned expression, whose result comes from the storage + // the walk just wrote into (the one read this method cannot avoid) + $storedAssignedExprResult = $assignedExpr === $target->getAssignedExpr() + ? $assignedValueResult ?? $storage->findExpressionResult($assignedExpr) + : $storage->findExpressionResult($assignedExpr); + $assignedValueResult = $storedAssignedExprResult; + $type = $this->readAssignedValueType($nodeScopeResolver, $storedAssignedExprResult, $assignedExpr, $scopeBeforeAssignEval); $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { - $if = $assignedExpr->if; - if ($if === null) { - $if = $assignedExpr->cond; + // the walk already evaluated the arms on the cond-filtered + // scopes - read the captured results instead of re-walking + $capturedTernary = $this->ternaryHandler->getCapturedResults($assignedExpr); + if ($capturedTernary !== null) { + [$ternaryCondResult, $ternaryIfResult, $ternaryElseResult] = $capturedTernary; + $condScope = $ternaryCondResult->getScope(); + $truthySpecifiedTypes = $ternaryCondResult->getSpecifiedTypesForScope($condScope, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $ternaryCondResult->getSpecifiedTypesForScope($condScope, TypeSpecifierContext::createFalsey()); + $truthyType = $ternaryIfResult->getType(); + $falseyType = $ternaryElseResult->getType(); + } else { + $if = $assignedExpr->if; + if ($if === null) { + $if = $assignedExpr->cond; + } + $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); + $truthySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); + $falseySpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); + $truthyScope = $condScope->applySpecifiedTypes($truthySpecifiedTypes); + $falsyScope = $condScope->applySpecifiedTypes($falseySpecifiedTypes); + // the arms of this unwalked ternary are re-priced on the + // narrowed cond scopes - scope state answers plain reads, + // anything else is priced on demand + $truthyType = $nodeScopeResolver->findScopeStateType($if, $truthyScope) + ?? $nodeScopeResolver->processSyntheticOnDemand($if, $truthyScope)->getTypeOnScope($truthyScope, $truthyScope->nativeTypesPromoted); + $falseyType = $nodeScopeResolver->findScopeStateType($assignedExpr->else, $falsyScope) + ?? $nodeScopeResolver->processSyntheticOnDemand($assignedExpr->else, $falsyScope)->getTypeOnScope($falsyScope, $falsyScope->nativeTypesPromoted); } - $condScope = $nodeScopeResolver->processExprNode($stmt, $assignedExpr->cond, $scope, $storage->duplicate(), new NoopNodeCallback(), ExpressionContext::createDeep())->getScope(); - $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createTruthy()); - $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($condScope, $assignedExpr->cond, TypeSpecifierContext::createFalsey()); - $truthyScope = $condScope->applySpecifiedTypes($truthySpecifiedTypes); - $falsyScope = $condScope->applySpecifiedTypes($falseySpecifiedTypes); - $truthyType = $truthyScope->getType($if); - $falseyType = $falsyScope->getType($assignedExpr->else); if ( $truthyType->isSuperTypeOf($falseyType)->no() && $falseyType->isSuperTypeOf($truthyType)->no() ) { - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $storage, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $storage, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $storage, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $storage, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); } } if ($assignedExpr instanceof Match_) { $conditionalExpressions = $this->mergeConditionalExpressions( $conditionalExpressions, - $this->processMatchForConditionalExpressionsAfterAssign($scopeBeforeAssignEval, $var->name, $assignedExpr), + $this->processMatchForConditionalExpressionsAfterAssign($nodeScopeResolver, $scopeBeforeAssignEval, $storage, $var->name, $assignedExpr), ); } + $assignedArgResult = $this->identicalNarrowingHelper->captureFirstArgResult($assignedExpr, $storage); + $truthyType = TypeCombinator::removeFalsey($type); // Value comparison, not identity: remove() happens to hand back the very same // instance when it removes nothing, but that is not part of its contract — the @@ -907,14 +1100,18 @@ public function applyWrite( // a fast path (equals() has no such shortcut, and no-op removal is the common // case here). if ($truthyType !== $type && !$truthyType->equals($type)) { - $truthySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + $truthySpecifiedTypes = $storedAssignedExprResult !== null + ? $storedAssignedExprResult->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy()) + : $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); $falseyType = TypeCombinator::intersect($type, StaticTypeFactory::falsey()); - $falseySpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $falseySpecifiedTypes = $storedAssignedExprResult !== null + ? $storedAssignedExprResult->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey()) + : $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); } foreach ([null, false, 0, 0.0, '', '0', []] as $falseyScalar) { @@ -941,25 +1138,36 @@ public function applyWrite( $astNode = new Node\Expr\Array_($falseyScalar); } - $notIdenticalConditionExpr = new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode); - $notIdenticalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); - - $identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, $astNode); - $identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); - $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); - $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + // the identical verdict of "assigned expr vs the sentinel": + // the loop guarantees the sentinel is a possible value, so + // only always-the-sentinel is decided + $identicalTypeCallback = static fn (): Type => $type->equals($falseyType) + ? new ConstantBooleanType(true) + : new BooleanType(); + + $notIdenticalSpecifiedTypes = $storedAssignedExprResult !== null + ? $this->identicalNarrowingHelper->specifyIdenticalAgainstType($assignedExpr, $storedAssignedExprResult, $astNode, $falseyType, TypeSpecifierContext::createFalse(), $scope, $assignedArgResult, $identicalTypeCallback) + : null; + $notIdenticalSpecifiedTypes ??= $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\NotIdentical($assignedExpr, $astNode), TypeSpecifierContext::createTrue()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + + $identicalSpecifiedTypes = $storedAssignedExprResult !== null + ? $this->identicalNarrowingHelper->specifyIdenticalAgainstType($assignedExpr, $storedAssignedExprResult, $astNode, $falseyType, TypeSpecifierContext::createTrue(), $scope, $assignedArgResult, $identicalTypeCallback) + : null; + $identicalSpecifiedTypes ??= $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($assignedExpr, $astNode), TypeSpecifierContext::createTrue()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $storage, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); } $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes()); + $scope = $scope->assignVariable($var->name, $type, $this->readAssignedValueType($nodeScopeResolver, $storedAssignedExprResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()), TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } if ($assignedExpr instanceof Expr\Array_) { - $scope = $this->processArrayByRefItems($scope, $var->name, $assignedExpr, new Variable($var->name)); + $scope = $this->processArrayByRefItems($nodeScopeResolver, $scope, $storage, $var->name, $assignedExpr, new Variable($var->name)); } } elseif ($target->getVariableNameResult() === null) { // a plain assignment does not read the target, so the dynamic name @@ -976,17 +1184,19 @@ public function applyWrite( if (!$var instanceof ArrayDimFetch) { throw new ShouldNotHappenException(); } - $originalVar = $var; $var = $target->getRootVar(); + $varResult = $target->getVarResult(); $dimFetchStack = $target->getDimFetchStack(); $assignedPropertyExpr = $target->getAssignedPropertyExpr(); $offsetTypes = $target->getOffsetTypes(); $offsetNativeTypes = $target->getOffsetNativeTypes(); - $valueToWrite = $scope->getType($assignedExpr); - $nativeValueToWrite = $scope->getNativeType($assignedExpr); + // 3. eval assigned expr first, then read the assigned value on the pre-eval + // scope - so the read consumes the now-stored result of $assignedExpr (and + // of its operands) instead of pricing unprocessed nodes (mirrors the + // Variable branch above). The ??= left side's optional array{} branch is + // preserved by the coalesce typeCallback carrying the isset descriptor, not + // by reading a stale resolvedTypes cache (bug-13623). $scopeBeforeAssignEval = $scope; - - // 3. eval assigned expr $result = $valueResult; $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); @@ -994,8 +1204,16 @@ public function applyWrite( $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $scope = $result->getScope(); - $varType = $scope->getType($var); - $varNativeType = $scope->getNativeType($var); + // read from the storage the walk just wrote into - the scope's storage + // stack misses it on loop-convergence passes (the temp storage is never + // pushed), which fell back to a full on-demand re-walk of the assigned + // expression for both flavours + $storedValueResult = $assignedValueResult ?? $storage->findExpressionResult($assignedExpr); + $nativeScopeBeforeAssignEval = $scopeBeforeAssignEval->doNotTreatPhpDocTypesAsCertain(); + $valueToWrite = $this->readAssignedValueType($nodeScopeResolver, $storedValueResult, $assignedExpr, $scopeBeforeAssignEval); + $nativeValueToWrite = $this->readAssignedValueType($nodeScopeResolver, $storedValueResult, $assignedExpr, $nativeScopeBeforeAssignEval); + + [$varType, $varNativeType] = $this->resolveContainerTypesAfterAssignedExprEval($nodeScopeResolver, $var, $varResult, $scope, $scopeBeforeAssignEval, $storage); // 4. compose types $isImplicitArrayCreation = $this->isImplicitArrayCreation($dimFetchStack, $scope); @@ -1006,10 +1224,10 @@ public function applyWrite( $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; - [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); + [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($nodeScopeResolver, $dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope, $storage); if (!$offsetValueType->equals($offsetNativeValueType) || !$valueToWrite->equals($nativeValueToWrite)) { - [$nativeValueToWrite, $additionalNativeExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); + [$nativeValueToWrite, $additionalNativeExpressions] = $this->produceArrayDimFetchAssignValueToWrite($nodeScopeResolver, $dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope, $storage); } else { $rewritten = false; foreach ($offsetTypes as $i => [$offsetType]) { @@ -1028,7 +1246,7 @@ public function applyWrite( continue; } - [$nativeValueToWrite] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); + [$nativeValueToWrite] = $this->produceArrayDimFetchAssignValueToWrite($nodeScopeResolver, $dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope, $storage); $rewritten = true; break; } @@ -1046,7 +1264,8 @@ public function applyWrite( if ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { - $scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString()); + // the chain root's receiver was walked by prepareTarget() + $scope = $scope->assignInitializedProperty($nodeScopeResolver->readStoredResult($var->var, $storage)->getTypeOnScope($scope, $scope->nativeTypesPromoted), $var->name->toString()); } } $scope = $scope->assignExpression( @@ -1061,7 +1280,8 @@ public function applyWrite( } elseif ($var instanceof PropertyFetch || $var instanceof StaticPropertyFetch) { $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedPropertyExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($var instanceof PropertyFetch && $var->name instanceof Node\Identifier && !$isAssignOp) { - $scope = $scope->assignInitializedProperty($scope->getType($var->var), $var->name->toString()); + // the chain root's receiver was walked by prepareTarget() + $scope = $scope->assignInitializedProperty($nodeScopeResolver->readStoredResult($var->var, $storage)->getTypeOnScope($scope, $scope->nativeTypesPromoted), $var->name->toString()); } } } @@ -1076,7 +1296,9 @@ public function applyWrite( $scope = $scope->assignExpression($expr, $type, $nativeType); } - $setVarType = $scope->getType($originalVar->var); + // the second-outermost chain link's result (for a single-dimension + // target: the root), threaded from the walk + $setVarType = $target->getOffsetSetTargetResult()->getTypeOnScope($scope, $scope->nativeTypesPromoted); if ( !$setVarType instanceof ErrorType && !$setVarType->isArray()->yes() @@ -1093,6 +1315,7 @@ public function applyWrite( if (!$var instanceof PropertyFetch) { throw new ShouldNotHappenException(); } + $objectResult = $target->getObjectResult(); $propertyName = $target->getPropertyName(); $scopeBeforeAssignEval = $scope; $result = $valueResult; @@ -1106,10 +1329,10 @@ public function applyWrite( $throwPoints[] = InternalThrowPoint::createImplicit($scope, $var); } - $propertyHolderType = $scope->getType($var->var); + $propertyHolderType = $objectResult->getType(); if ($propertyName !== null && $propertyHolderType->hasInstanceProperty($propertyName)->yes()) { $propertyReflection = $propertyHolderType->getInstanceProperty($propertyName, $scope); - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -1126,16 +1349,16 @@ public function applyWrite( } if ($assignedTypeIsCompatible) { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } else { $scope = $scope->assignExpression( $var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), - TypeCombinator::intersect($scope->getNativeType($assignedExpr)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), + TypeCombinator::intersect($this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), ); } } else { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } } $declaringClass = $propertyReflection->getDeclaringClass(); @@ -1170,11 +1393,10 @@ public function applyWrite( } } else { // fallback - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); - // simulate dynamic property assign by __set to get throw points; - // the receiver's own throw points were already collected by its walk + $scope = $scope->assignExpression($var, $assignedExprType, $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); + // simulate dynamic property assign by __set to get throw points if (!$propertyHolderType->hasMethod('__set')->no()) { $throwPoints = array_merge($throwPoints, $this->methodThrowPointHelper->getThrowPointsForCallOnType( $scope, @@ -1201,7 +1423,7 @@ public function applyWrite( if ($propertyName !== null) { $propertyReflection = $scope->getStaticPropertyReflection($propertyHolderType, $propertyName); - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection !== null && $propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -1218,23 +1440,23 @@ public function applyWrite( } if ($assignedTypeIsCompatible) { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } else { $scope = $scope->assignExpression( $var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), - TypeCombinator::intersect($scope->getNativeType($assignedExpr)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), + TypeCombinator::intersect($this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), ); } } else { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } } } else { // fallback - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain())); } } elseif ($kind === PreparedAssignTarget::KIND_LIST) { if (!$var instanceof List_) { @@ -1257,6 +1479,7 @@ public function applyWrite( } $itemScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($itemScope, $arrayItem->value); $nodeScopeResolver->callNodeCallback($nodeCallback, $arrayItem, $itemScope, $storage); + $keyResult = null; if ($arrayItem->key !== null) { $keyResult = $nodeScopeResolver->processExprNode($stmt, $arrayItem->key, $itemScope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $hasYield || $keyResult->hasYield(); @@ -1266,12 +1489,16 @@ public function applyWrite( $scope = $keyResult->getScope(); } - if ($arrayItem->key === null) { - $dimExpr = new Node\Scalar\Int_($i); + if ($keyResult !== null) { + $dimType = $keyResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); } else { - $dimExpr = $arrayItem->key; + $dimType = new ConstantIntegerType($i); } - $getOffsetValueTypeExpr = new TypeExpr($scope->getType($assignedExpr)->getOffsetValueType($scope->getType($dimExpr))); + $getOffsetValueTypeExpr = new TypeExpr($this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope)->getOffsetValueType($dimType)); + // store the fabricated result so narrowing walks over the item value + // compose from it instead of falling back to on-demand pricing + $itemValueResult = $this->virtualExprResultHelper->createTypeExprResult($scope, $getOffsetValueTypeExpr); + $nodeScopeResolver->storeExpressionResult($storage, $getOffsetValueTypeExpr, $itemValueResult); $itemTarget = $this->prepareTarget( $nodeScopeResolver, $scope, @@ -1286,7 +1513,18 @@ public function applyWrite( $result = $this->applyWrite( $nodeScopeResolver, $itemTarget, - $this->expressionResultFactory->create($itemTarget->getScope(), beforeScope: $itemTarget->getScope(), expr: $getOffsetValueTypeExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + $this->expressionResultFactory->create( + $itemTarget->getScope(), + beforeScope: $itemTarget->getScope(), + expr: $getOffsetValueTypeExpr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ), + $itemValueResult, $stmt, $storage, $nodeCallback, @@ -1300,13 +1538,13 @@ public function applyWrite( } } elseif ($kind === PreparedAssignTarget::KIND_EXISTING_ARRAY_DIM_FETCH) { $var = $target->getRootVar(); + $varResult = $target->getVarResult(); $assignedPropertyExpr = $target->getAssignedPropertyExpr(); $offsetTypes = $target->getExistingOffsetTypes(); $offsetNativeTypes = $target->getExistingOffsetNativeTypes(); - $valueToWrite = $scope->getType($assignedExpr); - $nativeValueToWrite = $scope->getNativeType($assignedExpr); - $varType = $scope->getType($var); - $varNativeType = $scope->getNativeType($var); + $valueToWrite = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope); + $nativeValueToWrite = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()); + [$varType, $varNativeType] = $this->resolveContainerTypesAfterAssignedExprEval($nodeScopeResolver, $var, $varResult, $scope, null, $storage); $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; @@ -1354,8 +1592,18 @@ public function applyWrite( $scope = $result->getScope(); } - // stored where processAssignVar is called - return $this->expressionResultFactory->create($scope, $beforeScope, $var, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + // stored where prepareTarget/applyWrite are called + return $this->expressionResultFactory->create( + $scope, + $beforeScope, + $var, + $hasYield, + $isAlwaysTerminating, + $throwPoints, + $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); } private function createArrayDimFetchConditionalExpressionHolder( @@ -1379,6 +1627,83 @@ private function createArrayDimFetchConditionalExpressionHolder( ]); } + /** + * The assigned value's type at the given scope, read off the threaded result + * when available. No threaded result means a virtual assign: the value is a + * synthetic node (TypeExpr, or a composed dim fetch enterForeach() tracks in + * scope state). + */ + private function readAssignedValueType(NodeScopeResolver $nodeScopeResolver, ?ExpressionResult $assignedValueResult, Expr $assignedExpr, MutatingScope $scope): Type + { + if ($assignedValueResult !== null) { + return $assignedValueResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + return $nodeScopeResolver->findScopeStateType($assignedExpr, $scope) + ?? $nodeScopeResolver->processSyntheticOnDemand($assignedExpr, $scope)->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + /** + * The container's (phpdoc, native) type pair AFTER the assigned expression + * ran. The target's pre-eval walk result is stale when the assigned + * expression changed the container ($arr[2] = f($arr = [...]), an impure + * call invalidating the fetched property): a variable root and a + * still-tracked fetch read the post-eval state, a fetch the assigned + * expression invalidated (tracked before the eval, untracked after) is + * re-priced on the post-eval scope, and an untracked-throughout root keeps + * the walk-position type - nothing the assigned expression did could have + * changed what it reads. + * + * @return array{Type, Type} + */ + private function resolveContainerTypesAfterAssignedExprEval( + NodeScopeResolver $nodeScopeResolver, + Expr $var, + ExpressionResult $varResult, + MutatingScope $postEvalScope, + ?MutatingScope $preEvalScope, + ExpressionResultStorage $storage, + ): array + { + if ($var instanceof Variable && is_string($var->name)) { + // A superglobal keeps composing over the pre-eval view: a volatile + // invalidation between the walk and this read (any maybe-impure call + // in the assigned expression) would otherwise degrade the write to + // the raw superglobal array (see bug-14999). + if ( + !in_array($var->name, Scope::SUPERGLOBAL_VARIABLES, true) + && !$varResult->askScopeVariableStateMatches($postEvalScope, false) + ) { + // the assigned expression reassigned the root variable itself + return [ + $postEvalScope->getVariableType($var->name), + $postEvalScope->doNotTreatPhpDocTypesAsCertain()->getVariableType($var->name), + ]; + } + + return [$varResult->getType(), $varResult->getNativeType()]; + } + + if ($postEvalScope->hasExpressionType($var)->yes()) { + return [ + $varResult->getTypeOnScope($postEvalScope, false), + $varResult->getTypeOnScope($postEvalScope, true), + ]; + } + + if ($preEvalScope !== null && $preEvalScope->hasExpressionType($var)->yes()) { + // a fetch the assigned expression invalidated (tracked before the + // eval, untracked after) - re-price it at the post-eval position + $reprocessed = $nodeScopeResolver->processExprOnDemand($var, $postEvalScope, $storage->duplicate()); + + return [$reprocessed->getType(), $reprocessed->getNativeType()]; + } + + // untracked throughout - nothing the assigned expression did could have + // changed what the walk read + return [$varResult->getType(), $varResult->getNativeType()]; + } + private function unwrapAssign(Expr $expr): Expr { if ($expr instanceof Assign) { @@ -1393,7 +1718,7 @@ private function unwrapAssign(Expr $expr): Expr * @param ImpurePoint[] $rhsImpurePoints * @return array */ - private function processSureTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array + private function processSureTypesForConditionalExpressionsAfterAssign(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr, ?ExpressionResult $assignedValueResult): array { foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1408,7 +1733,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $innerExpr, $this->exprPrinter->printExpr($innerExpr), - $scope->getType($innerExpr), + $this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $storage, $innerExpr, $assignedExpr, $assignedValueResult), TrinaryLogic::createMaybe(), ); continue; @@ -1422,7 +1747,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $expr, $exprString, - TypeCombinator::intersect($scope->getType($expr), $exprType), + TypeCombinator::intersect($this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $storage, $expr, $assignedExpr, $assignedValueResult), $exprType), TrinaryLogic::createYes(), ); } @@ -1435,7 +1760,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco * @param ImpurePoint[] $rhsImpurePoints * @return array */ - private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $scope, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr): array + private function processSureNotTypesForConditionalExpressionsAfterAssign(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, string $variableName, array $conditionalExpressions, SpecifiedTypes $specifiedTypes, Type $variableType, array $rhsImpurePoints, Expr $assignedExpr, ?ExpressionResult $assignedValueResult): array { foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1464,7 +1789,7 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ $variableType, $expr, $exprString, - TypeCombinator::remove($scope->getType($expr), $exprType), + TypeCombinator::remove($this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $storage, $expr, $assignedExpr, $assignedValueResult), $exprType), TrinaryLogic::createYes(), ); } @@ -1472,6 +1797,42 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ return $conditionalExpressions; } + /** + * Current type of a conditional-holder expression, used to refine the holder's + * projected type. Prefers the tracked scope state over the stored result, + * which can be stale after a by-ref write - e.g. + * preg_match($p, $s, $matches) updates $matches in the scope state but leaves the + * stored result from the earlier `$matches = []` untouched, so reading it back would + * intersect the matched shape against the stale array{} and collapse to NEVER. + */ + private function currentTypeForConditionalHolder(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, Expr $expr, Expr $assignedExpr, ?ExpressionResult $assignedValueResult): Type + { + // A by-ref write lands in the variable's tracked type, so read it from the + // scope state (getVariableType is null-safe for superglobals/undefined too). + // Method calls and other non-variable holder exprs have no by-ref hazard and + // keep reading their stored result. + if ($expr instanceof Variable && is_string($expr->name) && $scope->hasVariableType($expr->name)->yes()) { + return $scope->getVariableType($expr->name); + } + + // the assigned expression's own result is threaded in by the caller - its + // processing is still in flight, so an on-demand walk would re-enter it + if ($expr === $assignedExpr && $assignedValueResult !== null) { + return $assignedValueResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + // holder exprs are usually subexpressions of the walked condition - read + // them from the walk's own storage (the scope's storage stack misses it + // on loop-convergence passes); synthetic terms narrowing extensions built + // (@phpstan-assert property fetches etc.) answer from scope state or a walk + $storedResult = $storage->findExpressionResult($expr); + if ($storedResult !== null) { + return $storedResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + return $nodeScopeResolver->readScopeStateOrSyntheticType($expr, $scope); + } + /** * @param array $conditionalExpressions * @return array @@ -1530,13 +1891,17 @@ private function mergeConditionalExpressions(array $conditionalExpressions, arra * @return array */ private function processMatchForConditionalExpressionsAfterAssign( + NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, + ExpressionResultStorage $storage, string $variableName, Match_ $expr, ): array { - $armScopesAndTypes = $this->matchHandler->getArmScopesAndTypes($scope, $expr); - if (count($armScopesAndTypes) < 2) { + // the pairs were captured while the match (the assigned expression) was + // processed just above - no arm re-walk + $armScopesAndTypes = $this->matchHandler->getCapturedArmScopesAndTypes($expr); + if ($armScopesAndTypes === null || count($armScopesAndTypes) < 2) { return []; } @@ -1664,12 +2029,13 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr return $scope->hasVariableType($varNode->name)->negate(); } - private function processArrayByRefItems(MutatingScope $scope, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope + private function processArrayByRefItems(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, ExpressionResultStorage $storage, string $rootVarName, Expr\Array_ $arrayExpr, Expr $parentExpr): MutatingScope { $implicitIndex = 0; foreach ($arrayExpr->items as $arrayItem) { if ($arrayItem->key !== null) { - $keyType = $scope->getType($arrayItem->key)->toArrayKey(); + // the key was walked as part of the assigned array literal + $keyType = $nodeScopeResolver->readStoredResult($arrayItem->key, $storage)->getTypeOnScope($scope, $scope->nativeTypesPromoted)->toArrayKey(); if ($implicitIndex !== null) { $keyValues = $keyType->getConstantScalarValues(); @@ -1695,7 +2061,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam if ($arrayItem->value instanceof Expr\Array_) { $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); - $scope = $this->processArrayByRefItems($scope, $rootVarName, $arrayItem->value, $dimFetchExpr); + $scope = $this->processArrayByRefItems($nodeScopeResolver, $scope, $storage, $rootVarName, $arrayItem->value, $dimFetchExpr); } if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { @@ -1704,8 +2070,11 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam $refVarName = $arrayItem->value->name; $dimFetchExpr = new ArrayDimFetch($parentExpr, $dimExpr); - $refType = $scope->getType(new Variable($refVarName)); - $refNativeType = $scope->getNativeType(new Variable($refVarName)); + // a plain variable read is scope state - no need to price a synthetic + // Variable node on demand (mirrors VariableHandler's typeCallback) + $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); + $refType = $scope->hasVariableType($refVarName)->no() ? new ErrorType() : $scope->getVariableType($refVarName); + $refNativeType = $nativeScope->hasVariableType($refVarName)->no() ? new ErrorType() : $nativeScope->getVariableType($refVarName); // When $rootVarName's array key changes, update $refVarName $scope = $scope->assignExpression( @@ -1733,7 +2102,7 @@ private function processArrayByRefItems(MutatingScope $scope, string $rootVarNam * * @return array{Type, list} */ - private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, array $offsetTypes, Type $offsetValueType, Type $valueToWrite, Scope $scope): array + private function produceArrayDimFetchAssignValueToWrite(NodeScopeResolver $nodeScopeResolver, array $dimFetchStack, array $offsetTypes, Type $offsetValueType, Type $valueToWrite, MutatingScope $scope, ExpressionResultStorage $storage): array { $originalValueToWrite = $valueToWrite; @@ -1752,14 +2121,14 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar $has = $offsetValueType->hasOffsetValueType($offsetType); if ($has->yes()) { if ($scope->hasExpressionType($dimFetch)->yes()) { - $offsetValueType = $scope->getType($dimFetch); + $offsetValueType = $scope->getStateType($dimFetch); } else { $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); } } elseif ($has->maybe()) { if ($scope->hasExpressionType($dimFetch)->yes()) { $generalizeOnWrite = false; - $offsetValueType = $scope->getType($dimFetch); + $offsetValueType = $scope->getStateType($dimFetch); } else { $offsetValueType = TypeCombinator::union($offsetValueType->getOffsetValueType($offsetType), new ConstantArrayType([], [])); } @@ -1837,7 +2206,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar $valueToWrite = $offsetValueType->setOffsetValueType($offsetType, $valueToWrite, $unionValues); } - if ($arrayDimFetch !== null && $offsetValueType->isList()->yes() && $this->shouldKeepList($arrayDimFetch, $scope, $offsetValueType)) { + if ($arrayDimFetch !== null && $offsetValueType->isList()->yes() && $this->shouldKeepList($nodeScopeResolver, $arrayDimFetch, $scope, $storage, $offsetValueType)) { $valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType()); } @@ -1860,7 +2229,11 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } elseif (isset($computedContainerValues[$key])) { $additionalValueType = $computedContainerValues[$key]; } else { - $offsetType = $scope->getType($dimFetch->dim); + // the dimension's walk-captured type, aligned with the stack by key + $offsetType = $offsetTypes[$key][0]; + if ($offsetType === null) { + throw new ShouldNotHappenException(); + } $additionalValueType = $valueToWrite->getOffsetValueType($offsetType); } @@ -1870,7 +2243,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar return [$valueToWrite, $additionalExpressions]; } - private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type $offsetValueType): bool + private function shouldKeepList(NodeScopeResolver $nodeScopeResolver, ArrayDimFetch $arrayDimFetch, MutatingScope $scope, ExpressionResultStorage $storage, Type $offsetValueType): bool { if ($arrayDimFetch->dim instanceof Expr\BinaryOp\Plus) { if ( // keep list for $list[$index + 1] assignments @@ -1896,7 +2269,8 @@ private function shouldKeepList(ArrayDimFetch $arrayDimFetch, Scope $scope, Type && in_array($arrayDimFetch->dim->left->name->toLowerString(), ['count', 'sizeof'], true) && count($arrayDimFetch->dim->left->getArgs()) === 1 // could support COUNT_RECURSIVE, COUNT_NORMAL && $this->isSameVariable($arrayDimFetch->var, $arrayDimFetch->dim->left->getArgs()[0]->value) - && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($scope->getType($arrayDimFetch->dim))->yes() + // the dimension was walked as part of the assign target chain + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($nodeScopeResolver->readStoredResult($arrayDimFetch->dim, $storage)->getTypeOnScope($scope, $scope->nativeTypesPromoted))->yes() && $offsetValueType->isIterableAtLeastOnce()->yes() ) { return true; @@ -1934,12 +2308,22 @@ private function isSameVariable(Expr $a, Expr $b): bool * Returns the property's readable (declared) type, filtered down to the union * members that are not disjoint from the currently narrowed property type. */ - private function getOriginalPropertyType(PropertyFetch|StaticPropertyFetch $propertyFetch, MutatingScope $scope): Type + private function getOriginalPropertyType(NodeScopeResolver $nodeScopeResolver, PropertyFetch|StaticPropertyFetch $propertyFetch, MutatingScope $scope): Type { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyFetch, $scope); + // the fetch is a write target inside an offset chain - nothing of it is + // processed yet, so the holder type is read maybe-stored (a plain variable + // receiver like $this answers from scope state without a walk) + if ($propertyFetch instanceof PropertyFetch) { + $propertyHolderType = $nodeScopeResolver->readTypeOfMaybeStored($propertyFetch->var, $scope); + } elseif ($propertyFetch->class instanceof Name) { + $propertyHolderType = $scope->resolveTypeByName($propertyFetch->class); + } else { + $propertyHolderType = $nodeScopeResolver->readTypeOfMaybeStored($propertyFetch->class, $scope); + } + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNodeWithHolderType($propertyFetch, $propertyHolderType, $scope); $originalPropertyType = $propertyReflection !== null ? $propertyReflection->getReadableType() : new ErrorType(); if ($originalPropertyType instanceof UnionType) { - $currentPropertyType = $scope->getType($propertyFetch); + $currentPropertyType = $nodeScopeResolver->readTypeOfMaybeStored($propertyFetch, $scope); $originalPropertyType = $originalPropertyType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); } diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 03669ecc0cf..1d5544aabb8 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -5,7 +5,6 @@ use DivisionByZeroError; use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; -use PhpParser\Node\Expr\BinaryOp; use PhpParser\Node\Stmt; use PHPStan\Analyser\AssignTargetWalkMode; use PHPStan\Analyser\ExpressionContext; @@ -13,20 +12,22 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\CoalesceCompositionHelper; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\CoalesceExpressionNode; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; +use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Type; use function array_merge; use function get_class; @@ -45,6 +46,8 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private CoalesceCompositionHelper $coalesceCompositionHelper, ) { } @@ -57,6 +60,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $beforeScope = $scope; + $target = $this->assignHandler->prepareTarget( $nodeScopeResolver, $scope, @@ -68,15 +72,22 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $context, $expr instanceof Expr\AssignOp\Coalesce ? AssignTargetWalkMode::coalesceReadModifyWrite() : AssignTargetWalkMode::readModifyWrite(), ); - $condResult = $expr instanceof Expr\AssignOp\Coalesce ? $target->getTargetReadResult() : null; + $targetReadResult = $target->getTargetReadResult(); + $condResult = $expr instanceof Expr\AssignOp\Coalesce ? $targetReadResult : null; + $chainResults = $target->getTargetChainResults(); + $rightResult = null; $valueBeforeScope = $target->getScope(); $valueScope = $valueBeforeScope; $valueContext = $context; if ($expr instanceof Expr\AssignOp\Coalesce) { - $valueScope = $valueScope->filterByFalseyValue( - new Expr\Isset_([$expr->var]), - ); + if ($condResult === null) { + throw new ShouldNotHappenException(); + } + + // the value expr only evaluates when the left side is null or + // unset - the falsey isset() narrowing, composed from the left read + $valueScope = $valueScope->applySpecifiedTypes($this->coalesceCompositionHelper->getRightSideScopeSpecifiedTypes($valueScope, $expr->var, $condResult, $chainResults, $expr)); if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { $valueContext = $valueContext->enterRightSideAssign( @@ -87,23 +98,155 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $valueResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $valueScope, $storage, $nodeCallback, $valueContext->enterDeep()); + $rhsResult = $valueResult; if ($expr instanceof Expr\AssignOp\Coalesce) { - $isAlwaysTerminatingCoalesce = $valueResult->isAlwaysTerminating() && $valueBeforeScope->getType($expr->var)->isNull()->yes(); + $rightResult = $valueResult; $valueResult = $this->expressionResultFactory->create( - $valueResult->getScope()->mergeWith($valueBeforeScope), + $rightResult->getScope()->mergeWith($valueBeforeScope), $valueBeforeScope, $expr->expr, - $valueResult->hasYield(), - $isAlwaysTerminatingCoalesce, - $valueResult->getThrowPoints(), - $valueResult->getImpurePoints(), + $rightResult->hasYield(), + $rightResult->isAlwaysTerminating() && $condResult->getType()->isNull()->yes(), + $rightResult->getThrowPoints(), + $rightResult->getImpurePoints(), + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope, $condResult, $chainResults, $rightResult, $targetReadResult, $rhsResult): Type { + // the operands' results are in hand: the target read from + // prepareTarget(), the value expr from the phase between + // prepareTarget() and applyWrite() - no storage round-trip + $getType = static function (Expr $e) use ($expr, $nodeScopeResolver, $beforeScope, $targetReadResult, $rhsResult, $nativeTypesPromoted): Type { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if ($e === $expr->var) { + return $targetReadResult->getTypeOnScope($s, $s->nativeTypesPromoted); + } + if ($e === $expr->expr) { + return $rhsResult->getTypeOnScope($s, $s->nativeTypesPromoted); + } + + // InitializerExprTypeResolver also asks about synthetic composed + // nodes (e.g. Mod($left, $right) for modulo bounds) - price those + return $nodeScopeResolver->processSyntheticOnDemand($e, $s)->getTypeOnScope($s, $s->nativeTypesPromoted); + }; + + if ($expr instanceof Expr\AssignOp\Coalesce) { + return $this->coalesceCompositionHelper->composeType( + $nodeScopeResolver, + $expr->var, + $condResult, + $rightResult, + $beforeScope, + $chainResults, + $expr, + $nativeTypesPromoted, + ); + } + + if ($expr instanceof Expr\AssignOp\Concat) { + return $this->initializerExprTypeResolver->getConcatType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\BitwiseAnd) { + return $this->initializerExprTypeResolver->getBitwiseAndType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\BitwiseOr) { + return $this->initializerExprTypeResolver->getBitwiseOrType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\BitwiseXor) { + return $this->initializerExprTypeResolver->getBitwiseXorType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Div) { + return $this->initializerExprTypeResolver->getDivType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Mod) { + return $this->initializerExprTypeResolver->getModType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Plus) { + return $this->initializerExprTypeResolver->getPlusType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Minus) { + return $this->initializerExprTypeResolver->getMinusType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Mul) { + return $this->initializerExprTypeResolver->getMulType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\Pow) { + return $this->initializerExprTypeResolver->getPowType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\ShiftLeft) { + return $this->initializerExprTypeResolver->getShiftLeftType($expr->var, $expr->expr, $getType); + } + + if ($expr instanceof Expr\AssignOp\ShiftRight) { + return $this->initializerExprTypeResolver->getShiftRightType($expr->var, $expr->expr, $getType); + } + + throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); + }; + $specifyTypesCallback = function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($expr, $condResult, $beforeScope): SpecifiedTypes { + $types = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + if (!$expr instanceof Expr\AssignOp\Coalesce || $context->null()) { + return $types; + } + + // a truthiness constraint on `$x ??= y` also constrains the assigned + // target - the specify-side mirror of the createTypesCallback below + // (the raw term on the assign node itself cannot be unpacked at the + // application point) + if (!$context->truthy()) { + $removedType = StaticTypeFactory::truthy(); + } elseif (!$context->falsey()) { + $removedType = StaticTypeFactory::falsey(); + } else { + return $types; + } + $cs = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + return $types->unionWith($this->defaultNarrowingHelper->createSubjectTypes($cs, $expr->var, $condResult, $removedType, TypeSpecifierContext::createFalse())->setRootExpr($expr)); + }; + $createTypesCallback = null; + if ($expr instanceof Expr\AssignOp\Coalesce) { + // a type constraint on `$x ??= y` constrains the assigned variable - + // what TypeSpecifier::create() recovered by its AssignOp\Coalesce arm + $createTypesCallback = function (Type $constraintType, TypeSpecifierContext $cctx, bool $nativeTypesPromoted) use ($expr, $condResult, $beforeScope): SpecifiedTypes { + $cs = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + + return $this->defaultNarrowingHelper->createSubjectTypes($cs, $expr->var, $condResult, $constraintType, $cctx); + }; + } + + // the result standing for the whole `$lvalue OP= value` expression - the + // value applyWrite() writes to the target + $assignOpValueResult = $this->expressionResultFactory->create( + $beforeScope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, + ); + $assignResult = $this->assignHandler->applyWrite( $nodeScopeResolver, $target, $valueResult, + $assignOpValueResult, $stmt, $storage, $nodeCallback, @@ -114,17 +257,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $assignResult->getImpurePoints(); if ( ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && - !$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + !$rhsResult->getTypeOnScope($scope, false)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() ) { $throwPoints[] = InternalThrowPoint::createExplicit($scope, new ObjectType(DivisionByZeroError::class), $expr, false); } if ($expr instanceof Expr\AssignOp\Concat) { - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($expr->expr, $scope, $rhsResult); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } - if ($condResult !== null) { + if ($expr instanceof Expr\AssignOp\Coalesce) { $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $condResult, 'on left side of ??='), $beforeScope, $storage, $context); } @@ -136,71 +279,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $assignResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $getType = static fn (Expr $expr): Type => $scope->getType($expr); - - if ($expr instanceof Expr\AssignOp\Coalesce) { - return $scope->getType(new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes())); - } - - if ($expr instanceof Expr\AssignOp\Concat) { - return $this->initializerExprTypeResolver->getConcatType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\BitwiseAnd) { - return $this->initializerExprTypeResolver->getBitwiseAndType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\BitwiseOr) { - return $this->initializerExprTypeResolver->getBitwiseOrType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\BitwiseXor) { - return $this->initializerExprTypeResolver->getBitwiseXorType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Div) { - return $this->initializerExprTypeResolver->getDivType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Mod) { - return $this->initializerExprTypeResolver->getModType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Plus) { - return $this->initializerExprTypeResolver->getPlusType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Minus) { - return $this->initializerExprTypeResolver->getMinusType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Mul) { - return $this->initializerExprTypeResolver->getMulType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\Pow) { - return $this->initializerExprTypeResolver->getPowType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\ShiftLeft) { - return $this->initializerExprTypeResolver->getShiftLeftType($expr->var, $expr->expr, $getType); - } - - if ($expr instanceof Expr\AssignOp\ShiftRight) { - return $this->initializerExprTypeResolver->getShiftRightType($expr->var, $expr->expr, $getType); - } - - throw new ShouldNotHappenException(sprintf('Unhandled %s', get_class($expr))); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php b/src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php new file mode 100644 index 00000000000..fdf9da51126 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php @@ -0,0 +1,127 @@ +getNativeType() : $varResult->getType()); + $varScalars = $varType->getConstantScalarValues(); + + if (count($varScalars) > 0) { + $newTypes = []; + + foreach ($varScalars as $varValue) { + if ($increment) { + if ($varValue === '') { + $varValue = '1'; + } elseif (is_string($varValue) && !is_numeric($varValue)) { + try { + $varValue = str_increment($varValue); + } catch (ValueError) { + return new NeverType(); + } + } elseif (!is_bool($varValue)) { + ++$varValue; + } + } else { + if ($varValue === '') { + $varValue = -1; + } elseif (is_string($varValue) && !is_numeric($varValue)) { + try { + $varValue = str_decrement($varValue); + } catch (ValueError) { + return new NeverType(); + } + } elseif (is_numeric($varValue)) { + --$varValue; + } + } + + $newTypes[] = ConstantTypeHelper::getTypeFromValue($varValue); + } + return TypeCombinator::union(...$newTypes); + } elseif ($varType->isString()->yes()) { + if ($varType->isLiteralString()->yes()) { + return new IntersectionType([ + new StringType(), + new AccessoryLiteralStringType(), + ]); + } + + if ($varType->isNumericString()->yes()) { + return new BenevolentUnionType([ + new IntegerType(), + new FloatType(), + ]); + } + + return new BenevolentUnionType([ + new StringType(), + new IntegerType(), + new FloatType(), + ]); + } + + $one = new Int_(1); + $getType = static function (Expr $e) use ($nativeTypesPromoted, $varExpr, $varResult, $one): Type { + if ($e === $varExpr) { + return $nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType(); + } + if ($e === $one) { + return new ConstantIntegerType(1); + } + + throw new ShouldNotHappenException(); + }; + + return $increment + ? $this->initializerExprTypeResolver->getPlusType($varExpr, $one, $getType) + : $this->initializerExprTypeResolver->getMinusType($varExpr, $one, $getType); + }; + } + +} diff --git a/src/Analyser/ExprHandler/PostDecHandler.php b/src/Analyser/ExprHandler/PostDecHandler.php index ecdf3bd84d8..d2a76bb8cc4 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -11,11 +11,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IncDecTypeHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; @@ -27,7 +27,11 @@ final class PostDecHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private IncDecTypeHelper $incDecTypeHelper, + ) { } @@ -40,14 +44,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the virtual assign writes the decremented value - hand it the synthetic's + // result so applyWrite composes off it instead of pricing the + // unprocessed synthetic (and sentinel comparisons against it) on demand + $virtualExpr = new PreDec($expr->var); + $virtualExprResult = $this->expressionResultFactory->create( + $varResult->getScope(), + beforeScope: $scope, + expr: $virtualExpr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, false), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($virtualExpr, $context), + ); + return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, $stmt, $expr->var, - new PreDec($expr->var), + $virtualExpr, $nodeCallback, + $virtualExprResult, )->getScope(), beforeScope: $scope, expr: $expr, @@ -55,17 +76,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + // post-decrement evaluates to the variable's pre-mutation value + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->var); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/PostIncHandler.php b/src/Analyser/ExprHandler/PostIncHandler.php index 9a68af90336..ecc5af39861 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -11,11 +11,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IncDecTypeHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Type\Type; @@ -27,7 +27,11 @@ final class PostIncHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private IncDecTypeHelper $incDecTypeHelper, + ) { } @@ -40,14 +44,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + // the virtual assign writes the incremented value - hand it the synthetic's + // result so applyWrite composes off it instead of pricing the + // unprocessed synthetic (and sentinel comparisons against it) on demand + $virtualExpr = new PreInc($expr->var); + $virtualExprResult = $this->expressionResultFactory->create( + $varResult->getScope(), + beforeScope: $scope, + expr: $virtualExpr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, true), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($virtualExpr, $context), + ); + return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), $storage, $stmt, $expr->var, - new PreInc($expr->var), + $virtualExpr, $nodeCallback, + $virtualExprResult, )->getScope(), beforeScope: $scope, expr: $expr, @@ -55,17 +76,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + // post-increment evaluates to the variable's pre-mutation value + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->var); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/PreDecHandler.php b/src/Analyser/ExprHandler/PreDecHandler.php index 6569fde8c10..9e4713793a0 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -3,36 +3,20 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Minus; use PhpParser\Node\Expr\PreDec; -use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IncDecTypeHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\Accessory\AccessoryLiteralStringType; -use PHPStan\Type\BenevolentUnionType; -use PHPStan\Type\FloatType; -use PHPStan\Type\IntegerType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\NeverType; -use PHPStan\Type\StringType; -use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use ValueError; -use function count; -use function is_numeric; -use function is_string; -use function str_decrement; /** * @implements ExprHandler @@ -41,7 +25,11 @@ final class PreDecHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private IncDecTypeHelper $incDecTypeHelper, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -50,59 +38,29 @@ public function supports(Expr $expr): bool return $expr instanceof PreDec; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->var); - $varScalars = $varType->getConstantScalarValues(); - - if (count($varScalars) > 0) { - $newTypes = []; - - foreach ($varScalars as $varValue) { - if ($varValue === '') { - $varValue = -1; - } elseif (is_string($varValue) && !is_numeric($varValue)) { - try { - $varValue = str_decrement($varValue); - } catch (ValueError) { - return new NeverType(); - } - } elseif (is_numeric($varValue)) { - --$varValue; - } - - $newTypes[] = $scope->getTypeFromValue($varValue); - } - return TypeCombinator::union(...$newTypes); - } elseif ($varType->isString()->yes()) { - if ($varType->isLiteralString()->yes()) { - return new IntersectionType([ - new StringType(), - new AccessoryLiteralStringType(), - ]); - } - - if ($varType->isNumericString()->yes()) { - return new BenevolentUnionType([ - new IntegerType(), - new FloatType(), - ]); - } - - return new BenevolentUnionType([ - new StringType(), - new IntegerType(), - new FloatType(), - ]); - } - - return $scope->getType(new Minus($expr->var, new Int_(1))); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $typeCallback = $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, false); + $specifyTypesCallback = fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + + // the result standing for the whole inc/dec expression - threaded into + // processVirtualAssign() as the value to assign so applyWrite() reads it + // directly instead of re-processing the node on demand (which would + // recurse) + $incDecValueResult = $this->expressionResultFactory->create( + $varResult->getScope(), + beforeScope: $scope, + expr: $expr, + hasYield: $varResult->hasYield(), + isAlwaysTerminating: $varResult->isAlwaysTerminating(), + throwPoints: $varResult->getThrowPoints(), + impurePoints: $varResult->getImpurePoints(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + ); + return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), @@ -111,6 +69,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, $expr, $nodeCallback, + $incDecValueResult, )->getScope(), beforeScope: $scope, expr: $expr, @@ -118,12 +77,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/ExprHandler/PreIncHandler.php b/src/Analyser/ExprHandler/PreIncHandler.php index 7d4be597076..cd603c20a7a 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -3,37 +3,20 @@ namespace PHPStan\Analyser\ExprHandler; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\BinaryOp\Plus; use PhpParser\Node\Expr\PreInc; -use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\IncDecTypeHelper; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; -use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\Accessory\AccessoryLiteralStringType; -use PHPStan\Type\BenevolentUnionType; -use PHPStan\Type\FloatType; -use PHPStan\Type\IntegerType; -use PHPStan\Type\IntersectionType; -use PHPStan\Type\NeverType; -use PHPStan\Type\StringType; -use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use ValueError; -use function count; -use function is_bool; -use function is_numeric; -use function is_string; -use function str_increment; /** * @implements ExprHandler @@ -42,7 +25,11 @@ final class PreIncHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private IncDecTypeHelper $incDecTypeHelper, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -51,59 +38,29 @@ public function supports(Expr $expr): bool return $expr instanceof PreInc; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varType = $scope->getType($expr->var); - $varScalars = $varType->getConstantScalarValues(); - - if (count($varScalars) > 0) { - $newTypes = []; - - foreach ($varScalars as $varValue) { - if ($varValue === '') { - $varValue = '1'; - } elseif (is_string($varValue) && !is_numeric($varValue)) { - try { - $varValue = str_increment($varValue); - } catch (ValueError) { - return new NeverType(); - } - } elseif (!is_bool($varValue)) { - ++$varValue; - } - - $newTypes[] = $scope->getTypeFromValue($varValue); - } - return TypeCombinator::union(...$newTypes); - } elseif ($varType->isString()->yes()) { - if ($varType->isLiteralString()->yes()) { - return new IntersectionType([ - new StringType(), - new AccessoryLiteralStringType(), - ]); - } - - if ($varType->isNumericString()->yes()) { - return new BenevolentUnionType([ - new IntegerType(), - new FloatType(), - ]); - } - - return new BenevolentUnionType([ - new StringType(), - new IntegerType(), - new FloatType(), - ]); - } - - return $scope->getType(new Plus($expr->var, new Int_(1))); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $typeCallback = $this->incDecTypeHelper->getTypeCallback($expr->var, $varResult, true); + $specifyTypesCallback = fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + + // the result standing for the whole inc/dec expression - threaded into + // processVirtualAssign() as the value to assign so applyWrite() reads it + // directly instead of re-processing the node on demand (which would + // recurse) + $incDecValueResult = $this->expressionResultFactory->create( + $varResult->getScope(), + beforeScope: $scope, + expr: $expr, + hasYield: $varResult->hasYield(), + isAlwaysTerminating: $varResult->isAlwaysTerminating(), + throwPoints: $varResult->getThrowPoints(), + impurePoints: $varResult->getImpurePoints(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + ); + return $this->expressionResultFactory->create( $nodeScopeResolver->processVirtualAssign( $varResult->getScope(), @@ -112,6 +69,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->var, $expr, $nodeCallback, + $incDecValueResult, )->getScope(), beforeScope: $scope, expr: $expr, @@ -119,12 +77,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - } diff --git a/src/Analyser/PreparedAssignTarget.php b/src/Analyser/PreparedAssignTarget.php index 6d119f23d69..f02ae9e2b16 100644 --- a/src/Analyser/PreparedAssignTarget.php +++ b/src/Analyser/PreparedAssignTarget.php @@ -36,8 +36,9 @@ final class PreparedAssignTarget * @param non-empty-list|null $dimFetchStack * @param non-empty-list|null $offsetTypes * @param non-empty-list|null $offsetNativeTypes - * @param list|null $existingOffsetTypes - * @param list|null $existingOffsetNativeTypes + * @param non-empty-list|null $existingOffsetTypes + * @param non-empty-list|null $existingOffsetNativeTypes + * @param ExpressionResult[] $targetChainResults */ public function __construct( private string $kind, @@ -52,15 +53,19 @@ public function __construct( private array $impurePoints, private bool $isAlwaysTerminating, private ?Expr $rootVar = null, + private ?ExpressionResult $varResult = null, private ?array $dimFetchStack = null, private ?Expr $assignedPropertyExpr = null, private ?array $offsetTypes = null, private ?array $offsetNativeTypes = null, private ?array $existingOffsetTypes = null, private ?array $existingOffsetNativeTypes = null, + private ?ExpressionResult $offsetSetTargetResult = null, + private ?ExpressionResult $objectResult = null, private ?string $propertyName = null, private ?Type $propertyHolderType = null, private ?ExpressionResult $targetReadResult = null, + private array $targetChainResults = [], private ?ExpressionResult $variableNameResult = null, ) { @@ -140,6 +145,15 @@ public function getRootVar(): Expr return $this->rootVar; } + public function getVarResult(): ExpressionResult + { + if ($this->varResult === null) { + throw new ShouldNotHappenException(); + } + + return $this->varResult; + } + /** * @return non-empty-list */ @@ -186,7 +200,7 @@ public function getOffsetNativeTypes(): array } /** - * @return list + * @return non-empty-list */ public function getExistingOffsetTypes(): array { @@ -198,7 +212,7 @@ public function getExistingOffsetTypes(): array } /** - * @return list + * @return non-empty-list */ public function getExistingOffsetNativeTypes(): array { @@ -209,6 +223,29 @@ public function getExistingOffsetNativeTypes(): array return $this->existingOffsetNativeTypes; } + /** + * The chain link an ArrayAccess::offsetSet would be invoked on: the + * second-outermost link's write-flavoured result, or the root's result for + * a single-dimension target. + */ + public function getOffsetSetTargetResult(): ExpressionResult + { + if ($this->offsetSetTargetResult === null) { + throw new ShouldNotHappenException(); + } + + return $this->offsetSetTargetResult; + } + + public function getObjectResult(): ExpressionResult + { + if ($this->objectResult === null) { + throw new ShouldNotHappenException(); + } + + return $this->objectResult; + } + public function getPropertyName(): ?string { return $this->propertyName; @@ -225,8 +262,7 @@ public function getPropertyHolderType(): Type /** * The whole target priced as a read - produced only in the - * read-modify-write walk modes. For `$lvalue ??= ...` the read carries the - * isset descriptor, composed from the walked chain (bug-13623). + * read-modify-write walk modes. */ public function getTargetReadResult(): ExpressionResult { @@ -237,6 +273,14 @@ public function getTargetReadResult(): ExpressionResult return $this->targetReadResult; } + /** + * @return ExpressionResult[] + */ + public function getTargetChainResults(): array + { + return $this->targetChainResults; + } + /** * A dynamic variable name (`$$name`) already walked by prepareTarget() - * read-modify-write targets evaluate the name before reading the old value. diff --git a/src/Node/Expr/ExistingArrayDimFetch.php b/src/Node/Expr/ExistingArrayDimFetch.php index 95c5aabd5c1..7e14f02f42e 100644 --- a/src/Node/Expr/ExistingArrayDimFetch.php +++ b/src/Node/Expr/ExistingArrayDimFetch.php @@ -6,6 +6,12 @@ use PhpParser\Node\Expr; use PHPStan\Node\VirtualNode; +/** + * The chain links reference the original, already-processed AST nodes, so + * consumers read their stored results instead of re-walking. No results are + * carried here: these wrappers end up inside scope-held synthetic expressions, + * and a carried result would pin its whole scope graph. + */ final class ExistingArrayDimFetch extends Expr implements VirtualNode { diff --git a/src/Rules/Properties/PropertyReflectionFinder.php b/src/Rules/Properties/PropertyReflectionFinder.php index b25682687b0..a585c613f53 100644 --- a/src/Rules/Properties/PropertyReflectionFinder.php +++ b/src/Rules/Properties/PropertyReflectionFinder.php @@ -83,6 +83,36 @@ public function findPropertyReflectionsFromNode($propertyFetch, Scope $scope): a return $reflections; } + /** + * Variant of findPropertyReflectionFromNode() for callers that already hold + * the property holder's type (e.g. from an ExpressionResult) - the receiver + * is not re-read through Scope::getType(). + * + * @param Node\Expr\PropertyFetch|Node\Expr\StaticPropertyFetch $propertyFetch + */ + public function findPropertyReflectionFromNodeWithHolderType($propertyFetch, Type $propertyHolderType, Scope $scope): ?FoundPropertyReflection + { + if ($propertyFetch instanceof Node\Expr\PropertyFetch) { + if ($propertyFetch->name instanceof Node\Identifier) { + return $this->findInstancePropertyReflection($propertyHolderType, $propertyFetch->name->name, $scope); + } + + $nameType = $scope->getType($propertyFetch->name); + $nameTypeConstantStrings = $nameType->getConstantStrings(); + if (count($nameTypeConstantStrings) === 1) { + return $this->findInstancePropertyReflection($propertyHolderType, $nameTypeConstantStrings[0]->getValue(), $scope); + } + + return null; + } + + if (!$propertyFetch->name instanceof Node\Identifier) { + return null; + } + + return $this->findStaticPropertyReflection($propertyHolderType, $propertyFetch->name->name, $scope); + } + /** * @param Node\Expr\PropertyFetch|Node\Expr\StaticPropertyFetch $propertyFetch */ diff --git a/tests/PHPStan/Analyser/nsrt/assign-in-array.php b/tests/PHPStan/Analyser/nsrt/assign-in-array.php new file mode 100644 index 00000000000..955df512571 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/assign-in-array.php @@ -0,0 +1,23 @@ +}> + */ + public function bar(): Generator + { + yield 'foo' => [ + $a = 'string', + ['string' => $a], + ]; + } + + public function baz(): void + { + $value = [ + $a = 'string', + ['string' => $a], + ]; + assertType("array{'string', array{string: 'string'}}", $value); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-13944.php b/tests/PHPStan/Analyser/nsrt/bug-13944.php new file mode 100644 index 00000000000..ed1aeaf31b0 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13944.php @@ -0,0 +1,48 @@ +, + * "when@stage"?: array, + * } $config + */ +function config(array $config): void +{ +} + +config([ + 'when@dev' => $does_not_work = [ + 'controllers' => [ + 'resource' => 'routing.controllers', + ], + ], + 'when@stage' => $does_not_work, +]); + +assertType("array{'when@dev': array{controllers: array{resource: 'routing.controllers'}}, 'when@stage': array{controllers: array{resource: 'routing.controllers'}}}", [ + 'when@dev' => $does_not_work, + 'when@stage' => $does_not_work, +]); + +assertType("array{'when@dev': array{controllers: array{resource: 'routing.controllers'}}, 'when@stage': array{controllers: array{resource: 'routing.controllers'}}}", [ + 'when@dev' => $defined_inside = [ + 'controllers' => [ + 'resource' => 'routing.controllers', + ], + ], + 'when@stage' => $defined_inside, +]); + +$does_work = [ + 'controllers' => [ + 'resource' => 'routing.controllers', + ], +]; +config([ + 'when@dev' => $does_work, + 'when@stage' => $does_work, +]); diff --git a/tests/PHPStan/Analyser/nsrt/bug-14999.php b/tests/PHPStan/Analyser/nsrt/bug-14999.php new file mode 100644 index 00000000000..be3b573d4b2 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14999.php @@ -0,0 +1,17 @@ +getDisplay(); +assertType('array{message: string}', $_SESSION['Import_message']); +$_SESSION['Import_message']['go_back_url'] = 'https://example.com/index.php?route=/server/import'; +assertType("array{message: string, go_back_url: 'https://example.com/index.php?route=/server/import'}", $_SESSION['Import_message']); diff --git a/tests/PHPStan/Analyser/nsrt/bug-7155.php b/tests/PHPStan/Analyser/nsrt/bug-7155.php new file mode 100644 index 00000000000..13fc56e4830 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7155.php @@ -0,0 +1,16 @@ + */ + public array $prop = []; + + /** @phpstan-impure */ + public function resetProp(): int + { + $this->prop = []; + return 5; + } + +} + +function reassignedInRhs(): array +{ + $arr = ['a' => 1]; + $arr[2] = takesArray($arr = ['b' => 2]); + assertType('array{b: 2, 2: int}', $arr); + + return $arr; +} + +function propertyInvalidatedByImpureRhs(Holder $h): void +{ + $h->prop = [1 => 1]; + $h->prop[2] = $h->resetProp(); + assertType('non-empty-array&hasOffsetValue(2, int)', $h->prop); +} diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index 2f9fd6c6f82..2b93bd64e47 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -668,4 +668,9 @@ public function testInTrait(): void ]); } + public function testBug12780(): void + { + $this->analyse([__DIR__ . '/data/bug-12780.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-12780.php b/tests/PHPStan/Rules/Variables/data/bug-12780.php new file mode 100644 index 00000000000..fde00de1f28 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12780.php @@ -0,0 +1,29 @@ += 8.0 + +namespace Bug12780; + +class HelloWorld +{ + + public function sayHello(?int $count = null): void + { + $user = new \stdClass(); + $user->missedOne = []; + $user->missedTwo = []; + $user->missedMore = []; + + $variableName = match ($count) { + 0 => null, + 1 => 'missedOne', + 2 => 'missedTwo', + default => 'missedMore', + }; + + if ($variableName !== null) { + $user->$variableName['test'] ??= 0; + $user->$variableName['test']++; + + } + } + +} From da3de2f55cd859ef50f8e6ca185c94026527ea05 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:37 +0200 Subject: [PATCH 17/32] Answer type and narrowing questions from stored ExpressionResults The engine switch-over. MutatingScope::getType() routes handler-backed nodes to the current storage's stored result and falls back to an on-demand walk for synthetic nodes; specifyTypesInCondition() delegates the same way, applySpecifiedTypes() reads tracked holders and memoized on-demand pricings instead of calling getType(), and the scope-state read family (getStateType()) derives narrowable expressions' types from tracked state. NodeScopeResolver pushes a storage around every analysis unit, consumes stored results everywhere it used to ask the scope, narrows loop/switch/foreach scopes through the composed helpers, flushes pending fibers only at body boundaries, and resets per-file state through the tagged resettables. FiberNodeScopeResolver stores full results and memoizes on-demand flush walks per file; FiberScope answers settled stored results without a fiber switch. TypeSpecifier is dropped from the NodeScopeResolver constructor (the testing harness follows), precisely resolved class constants are no longer remembered as conditional expressions, and the baseline follows the moved code. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- phpstan-baseline.neon | 42 +- src/Analyser/Fiber/FiberNodeScopeResolver.php | 119 +- src/Analyser/Fiber/FiberScope.php | 24 + src/Analyser/MutatingScope.php | 783 +++++--- src/Analyser/NodeScopeResolver.php | 1779 ++++++++++++----- src/Analyser/TypeSpecifier.php | 410 +--- src/Testing/RuleTestCase.php | 1 - src/Testing/TypeInferenceTestCase.php | 1 - tests/PHPStan/Analyser/AnalyserTest.php | 10 +- .../Fiber/FiberNodeScopeResolverRuleTest.php | 1 - .../Fiber/FiberNodeScopeResolverTest.php | 1 - .../ReturnStatementsNodeSyntheticAskRule.php | 51 + ...turnStatementsNodeSyntheticAskRuleTest.php | 33 + tests/PHPStan/Analyser/TypeSpecifierTest.php | 20 +- .../data/return-statements-synthetic-ask.php | 18 + .../nsrt/myers-diff-loop-widening.php | 4 +- .../Classes/ClassConstantPhp74RuleTest.php | 68 + .../Rules/Classes/classConstantPhp74.neon | 5 + .../data/class-constant-on-expr-never.php | 21 + 19 files changed, 2156 insertions(+), 1235 deletions(-) create mode 100644 tests/PHPStan/Analyser/ReturnStatementsNodeSyntheticAskRule.php create mode 100644 tests/PHPStan/Analyser/ReturnStatementsNodeSyntheticAskRuleTest.php create mode 100644 tests/PHPStan/Analyser/data/return-statements-synthetic-ask.php create mode 100644 tests/PHPStan/Rules/Classes/ClassConstantPhp74RuleTest.php create mode 100644 tests/PHPStan/Rules/Classes/classConstantPhp74.neon create mode 100644 tests/PHPStan/Rules/Classes/data/class-constant-on-expr-never.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 76efc63f397..3873f4d7092 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -24,6 +24,12 @@ parameters: count: 3 path: src/Analyser/ExprHandler/AssignHandler.php + - + rawMessage: Casting to string something that's already string. + identifier: cast.useless + count: 3 + path: src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php + - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType @@ -36,40 +42,22 @@ parameters: count: 2 path: src/Analyser/ExprHandler/BinaryOpHandler.php - - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantBooleanType is error-prone and deprecated. Use Type::isTrue() or Type::isFalse() instead.' - identifier: phpstanApi.instanceofType - count: 1 - path: src/Analyser/ExprHandler/BooleanNotHandler.php - - - - rawMessage: 'Doing instanceof PHPStan\Type\ConstantScalarType is error-prone and deprecated. Use Type::isConstantScalarValue() or Type::getConstantScalarTypes() or Type::getConstantScalarValues() instead.' - identifier: phpstanApi.instanceofType - count: 2 - path: src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php - - - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantBooleanType is error-prone and deprecated. Use Type::isTrue() or Type::isFalse() instead.' - identifier: phpstanApi.instanceofType - count: 3 - path: src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType count: 2 - path: src/Analyser/ExprHandler/IssetHandler.php + path: src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php - rawMessage: 'Only numeric types are allowed in pre-increment, float|int|string|null given.' identifier: preInc.nonNumeric count: 1 - path: src/Analyser/ExprHandler/PreIncHandler.php + path: src/Analyser/ExprHandler/Helper/IncDecTypeHelper.php - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 1 + count: 4 path: src/Analyser/MutatingScope.php - @@ -114,18 +102,6 @@ parameters: count: 1 path: src/Analyser/RuleErrorTransformer.php - - - rawMessage: Casting to string something that's already string. - identifier: cast.useless - count: 2 - path: src/Analyser/ScopeOps.php - - - - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantBooleanType is error-prone and deprecated. Use Type::isTrue() or Type::isFalse() instead.' - identifier: phpstanApi.instanceofType - count: 2 - path: src/Analyser/TypeSpecifier.php - - rawMessage: 'Template type TNodeType is declared as covariant, but occurs in contravariant position in parameter node of method PHPStan\Collectors\Collector::processNode().' identifier: generics.variance diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 1d42e3b54ea..4bf53acc732 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -11,18 +11,40 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\PerFileAnalysisResettable; +use PHPStan\Analyser\ReadVariableStateSnapshot; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Type; use function array_pop; use function count; +use function get_class; use function get_debug_type; use function spl_object_id; +use function sprintf; #[AutowiredService(as: FiberNodeScopeResolver::class)] -final class FiberNodeScopeResolver extends NodeScopeResolver +final class FiberNodeScopeResolver extends NodeScopeResolver implements PerFileAnalysisResettable { + /** + * Last flush-priced answer per asked expression - see processPendingFibers(). + * + * Keyed by the asked node's spl_object_id(), with the node itself in the + * entry: the keys are usually synthetic nodes rules rebuild per ask, so a + * dead node's id can be reused - the identity check on the stored node + * rejects such stale hits, and the per-file reset releases the entries. + * + * @var array + */ + private array $flushedOnDemandResults = []; + + public function resetFileAnalysisState(): void + { + $this->flushedOnDemandResults = []; + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -38,7 +60,7 @@ public function callNodeCallback( // returns. Only the rule-facing remainder may be deferred to a fiber; // a rule parking on an unsettled expression must not delay gathering. while ($nodeCallback instanceof GatheringNodeCallback) { - ($nodeCallback->getGatherer())($node, $scope); + ($nodeCallback->getGatherer())($node, $scope->toFiberScope()); $nodeCallback = $nodeCallback->getInner(); } @@ -69,12 +91,7 @@ public function callNodeCallback( public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { - // The storage only ever answers type questions from FiberScope, which - // resolves them from the before-scope. Storing just the before-scope - // keeps the storage from pinning throw points, impure points, scope - // callbacks and the after-scope of every expression until the end of - // the file; a full result is wrapped on demand when a fiber asks. - $storage->storeBeforeScope($expr, $expressionResult->getBeforeScope()); + parent::storeExpressionResult($storage, $expr, $expressionResult); $this->processPendingFibersForRequestedExpr($storage, $expr, $expressionResult); } @@ -89,9 +106,9 @@ private function runFiberForNodeCallback( { while (!$fiber->isTerminated()) { if ($request instanceof ExpressionResultRequest) { - $beforeScope = $storage->findBeforeScope($request->expr); - if ($beforeScope !== null) { - $request = $fiber->resume($this->createBeforeScopeResult($beforeScope->toMutatingScope(), $request->expr)); + $expressionResult = $this->findSettledExpressionResult($storage, $request->expr); + if ($expressionResult !== null) { + $request = $fiber->resume($expressionResult); continue; } @@ -123,23 +140,76 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void start: foreach ($storage->pendingFibers as $exprId => $pendingList) { + // A fiber suspended on an expression that is still being processed + // must not be flushed here: this boundary is a nested statement list + // inside that very expression (e.g. an immediately-invoked closure's + // body). The fiber is resumed when the enclosing processExprNode + // stores the result. + if (isset($this->processingExprIds[$exprId])) { + continue; + } + unset($storage->pendingFibers[$exprId]); foreach ($pendingList as $pending) { $request = $pending['request']; - $beforeScope = $storage->findBeforeScope($request->expr); + $expressionResult = $storage->findExpressionResult($request->expr); - if ($beforeScope !== null) { + if ($expressionResult !== null) { throw new ShouldNotHappenException('Pending fibers at the end should be about synthetic nodes'); } + // Only nodes built during analysis (rules constructing synthetic + // comparisons, ArgumentsNormalizer rewrites, ...) should reach the + // on-demand path here. A node from the file's parsed AST left pending + // means a rule asked about its type but it was never processed and + // stored during natural traversal - a gap to fix at the producing + // handler. A node that WAS stored but whose per-body storage has been + // released since (class-level rules asking about gathered method-body + // exprs) is fine - the on-demand re-price below is the rule-facing + // bridge for those, same as in the other guards' processed check. + // Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. + if ( + self::$guardNewWorld + && isset(self::$guardRealExprIds[spl_object_id($request->expr)]) + && !isset(self::$guardProcessedExprIds[spl_object_id($request->expr)]) + ) { + throw new ShouldNotHappenException(sprintf( + 'Pending fiber about real AST node %s on line %d - it should have been processed and its result stored during natural traversal.', + get_class($request->expr), + $request->expr->getStartLine(), + )); + } + $fiber = $pending['fiber']; - // The synthetic node was never processed in the walk, so there is - // no stored before-scope to answer with. Resume with a result - // anchored to the asker's own scope - its consumers resolve the - // type on demand from the before-scope. - $request = $fiber->resume($this->createBeforeScopeResult($request->scope->toMutatingScope(), $request->expr)); + // Rules ask about the same (usually synthetic) node repeatedly across + // statement boundaries; the answer is reusable whenever nothing the + // expression reads changed since the walk. Only the state snapshot + // and the materialized types are retained - keeping the walk result + // would pin its scope and callback graphs for every parser-cached + // file. The hit is fabricated at the ask position, exactly where a + // fresh walk's result would sit. + $askScope = $request->scope->toMutatingScope(); + $memoEntry = $this->flushedOnDemandResults[spl_object_id($request->expr)] ?? null; + if ($memoEntry !== null && $memoEntry[0] === $request->expr && $memoEntry[1]->matches($askScope)) { + $expressionResult = $this->createEagerExpressionResult($askScope, $request->expr, $memoEntry[2], $memoEntry[3]); + } else { + // Process the node with a duplicated storage so that the result + // computed from the asker's scope does not poison the real storage. + $expressionResult = $this->processExprOnDemand( + $request->expr, + $askScope, + $storage->duplicate(), + ); + $this->flushedOnDemandResults[spl_object_id($request->expr)] = [ + $request->expr, + $expressionResult->takeReadVariableStateSnapshot(), + $expressionResult->getType(), + $expressionResult->getNativeType(), + ]; + } + $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); } @@ -164,17 +234,4 @@ private function processPendingFibersForRequestedExpr(ExpressionResultStorage $s } } - private function createBeforeScopeResult(MutatingScope $beforeScope, Expr $expr): ExpressionResult - { - return $this->expressionResultFactory->create( - $beforeScope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - } diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index d10a844a0c2..46dece54316 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -67,6 +67,19 @@ public function getType(Expr $node): Type return $node->getExprType(); } + if ( + !$this->nativeTypesPromoted + && count($this->truthyValueExprs) === 0 + && count($this->falseyValueExprs) === 0 + ) { + // the same settled result the suspend round-trip's find path would + // hand back - skip the two fiber switches for the stored ask + $storedResult = $this->findSettledStoredResult($node); + if ($storedResult !== null) { + return $storedResult->getType(); + } + } + /** @var ExpressionResult $expressionResult */ $expressionResult = Fiber::suspend( new ExpressionResultRequest($node, $this), @@ -102,6 +115,17 @@ public function getNativeType(Expr $expr): Type return $expr->getExprType(); } + if ( + !$this->nativeTypesPromoted + && count($this->truthyValueExprs) === 0 + && count($this->falseyValueExprs) === 0 + ) { + $storedResult = $this->findSettledStoredResult($expr); + if ($storedResult !== null) { + return $storedResult->getNativeType(); + } + } + /** @var ExpressionResult $expressionResult */ $expressionResult = Fiber::suspend( new ExpressionResultRequest($expr, $this), diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a1d20804cc2..db97cc74bbb 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -6,6 +6,7 @@ use PhpParser\Node\Arg; use PhpParser\Node\ComplexType; use PhpParser\Node\Expr; +use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\ClassConstFetch; use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\FuncCall; @@ -59,6 +60,7 @@ use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; +use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\Php\PhpFunctionFromParserNodeReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Reflection\PropertyReflection; @@ -122,12 +124,14 @@ use function count; use function ctype_alnum; use function explode; +use function get_class; use function implode; use function in_array; use function is_array; use function is_string; use function ltrim; use function md5; +use function spl_object_id; use function sprintf; use function str_starts_with; use function strlen; @@ -142,7 +146,6 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter { - public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; private const COMPLEX_UNION_TYPE_MEMBER_LIMIT = 8; /** Magic methods that let the author decide which properties survive a serialize()/unserialize() round trip. */ @@ -154,11 +157,8 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter */ public array $resolvedTypes = []; - /** @var array */ - private array $truthyScopes = []; - - /** @var array */ - private array $falseyScopes = []; + /** @var array */ + private array $pricedSpecifiedExprTypePairs = []; private ?self $fiberScope = null; @@ -192,6 +192,7 @@ public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, private Parser $parser, private ConstantResolver $constantResolver, + private ExpressionResultStorageStack $expressionResultStorageStack, protected ScopeContext $context, private PhpVersion $phpVersion, private AttributeReflectionFactory $attributeReflectionFactory, @@ -980,8 +981,18 @@ public function getAnonymousFunctionReflection(): ?ClosureType return $this->anonymousFunctionReflection; } + /** @api */ + public function getAnonymousFunctionReturnType(): ?Type + { + if ($this->anonymousFunctionReflection === null) { + return null; + } + + return $this->anonymousFunctionReflection->getReturnType(); + } + /** - * A copy of this scope with only the anonymous-function + * Returns a scope identical to this one but with the anonymous function * reflection replaced. The scope entered at a closure/arrow carries only a * shallow reflection (parameters + declared return); once the single body * walk has gathered the returns, the engine builds the refined ClosureType and @@ -1011,18 +1022,20 @@ public function withAnonymousFunctionReflection(ClosureType $anonymousFunctionRe } /** @api */ - public function getAnonymousFunctionReturnType(): ?Type + public function getType(Expr $node): Type { - if ($this->anonymousFunctionReflection === null) { - return null; + if ( + NodeScopeResolver::$guardNewWorld + && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) + && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) + ) { + throw new ShouldNotHappenException(sprintf( + 'getType() asked about non-synthetic %s on line %d before it was processed by processExprNode() - it should consume the node\'s ExpressionResult instead.', + get_class($node), + $node->getStartLine(), + )); } - return $this->anonymousFunctionReflection->getReturnType(); - } - - /** @api */ - public function getType(Expr $node): Type - { $type = ScopeOps::getTypeFromCache($this, $node, $key); if ($type !== null) { return $type; @@ -1131,9 +1144,7 @@ public function getClosureScopeCacheKey(?array $relevantRoots = null): string return md5(implode("\n", $parts)); } - /** - * @param list $roots - */ + /** @param list $roots */ private static function exprStringIsRootedIn(string $exprString, array $roots): bool { foreach ($roots as $root) { @@ -1167,181 +1178,255 @@ private function resolveType(string $exprString, Expr $node): Type return $expressionType; } + // NodeScopeResolver intercepts a first-class callable CallLike before the + // ExprHandler dispatch - no handler supports the original node, its closure + // type lives on the stored result's typeCallback (see the *CallableNode + // handlers), mirroring TypeSpecifier::specifyTypesInCondition(). + if ($node instanceof Expr\CallLike && $node->isFirstClassCallable()) { + return $this->resolveTypeOfNewWorldHandlerNode($node); + } + $exprHandler = ExprHandlerRegistry::resolve($node, $this->container); if ($exprHandler !== null) { - return $exprHandler->resolveType($this, $node); + return $this->resolveTypeOfNewWorldHandlerNode($node); } return new MixedType(); } /** - * @param callable(Type): ?bool $typeCallback + * Resolves the type of a node whose ExprHandler produced an ExpressionResult. + * The answer comes from the ExpressionResult stored during the analysis + * currently in progress (its eager type or typeCallback), or from processing + * the node on demand (synthetic nodes, or no analysis in progress at all). + * + * The scope deliberately does not reference the storage - that would create + * a reference cycle that never gets collected (see ExpressionResultStorageStack). */ - public function issetCheck(Expr $expr, callable $typeCallback, ?bool $result = null): ?bool - { - // mirrored in PHPStan\Rules\IssetCheck - if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { - $hasVariable = $this->hasVariableType($expr->name); - if ($hasVariable->maybe()) { - return null; - } - - if ($result === null) { - if ($hasVariable->yes()) { - if ($expr->name === '_SESSION') { - return null; - } - - return $typeCallback($this->getVariableType($expr->name)); + private function resolveTypeOfNewWorldHandlerNode(Expr $node): Type + { + // the hooks are the boundary between the rule-facing world and the + // engine - a rule's FiberScope must not flow into result callbacks or + // on-demand processing, where its suspending type asks crash outside + // a fiber + $scope = $this->toMutatingScope(); + $storage = $this->expressionResultStorageStack->getCurrent(); + $counterfactualAsk = false; + if ($storage !== null) { + $result = $storage->findExpressionResult($node); + if ($result !== null && $result->canResolveOwnType()) { + // a counterfactual ask (the asking scope re-binds a variable the + // expression reads, e.g. array_filter pricing its callback body + // per constant element) must re-price the node on that scope - + // the memoized walk-position type answers a different question + $counterfactualAsk = !$result->askScopeVariableStateMatches($scope, $scope->nativeTypesPromoted); + if (!$counterfactualAsk) { + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); } - - return false; } + } - return $result; - } elseif ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { - $type = $this->getType($expr->var); - if (!$type->isOffsetAccessible()->yes()) { - return $result ?? $this->issetCheckUndefined($expr->var); - } + // A closure/arrow function type is computed directly (as + // resolveCallableTypeForScope() also does) - never by processing it on + // demand, which would re-enter ClosureHandler::processExpr() endlessly. + // This answers both a closure whose result is not stored yet (its own + // body walk asks for its type, and a callable parameter is derived from + // it while it is being processed) and a closure passed as a call argument, + // whose result NodeScopeResolver stores without an eager type. + // getClosureType()'s own depth guard answers the self-by-ref ask. + if ($node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction) { + return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $node); + } - $dimType = $this->getType($expr->dim); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - if ($hasOffsetValue->no()) { - return false; - } + if (!$counterfactualAsk && $storage !== null && $storage->findExpressionResult($node) !== null) { + throw new ShouldNotHappenException(sprintf( + 'ExpressionResult of %s cannot resolve its own type (no eager type, no typeCallback).', + get_class($node), + )); + } - // If offset cannot be null, store this error message and see if one of the earlier offsets is. - // E.g. $array['a']['b']['c'] ?? null; is a valid coalesce if a OR b or C might be null. - if ($hasOffsetValue->yes()) { - $result = $typeCallback($type->getOffsetValueType($dimType)); + // a synthetic node, or no analysis in progress + $onDemandResult = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $node, + $scope, + $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), + ); - if ($result !== null) { - return $this->issetCheck($expr->var, $typeCallback, $result); - } - } + return $onDemandResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } - // Has offset, it is nullable + /** + * Prices the current (phpdoc, native) type pair of an expression that + * applySpecifiedTypes() needs to intersect with or subtract from but that + * is not tracked in the scope. Old-world filterBySpecifiedTypes() asked + * Scope::getType() here; pricing from the stored ExpressionResult answers + * through the typeCallback for converted handlers. A synthetic node the + * analysis never processed - e.g. the plain-chain variant a nullsafe + * narrowing emits ($a->b() alongside $a?->b()) - is priced on demand, + * mirroring resolveTypeOfNewWorldHandlerNode(); its real subnodes answer + * from stored results so the on-demand walk terminates. Returns null only + * when there is no analysis in progress to price against. + * + * @return array{Type, Type}|null + */ + private function getCurrentTypesOfSpecifiedExpr(Expr $expr): ?array + { + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage === null) { return null; + } - } elseif ($expr instanceof Node\Expr\PropertyFetch || $expr instanceof Node\Expr\StaticPropertyFetch) { - - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $this); - - if ($propertyReflection === null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } - - return null; - } - - if (!$propertyReflection->isNative()) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } + // a narrowable expression's scope-view type is derived from tracked + // state - the application-point semantics this method exists for. The + // stored result must NOT win here: a narrowing entry's node sits inside + // the condition (the \$a of `'' !== \$a`, walked on a truthy branch), so + // its walk-position type carries branch narrowing that would poison the + // base the narrowing is applied to. + if ( + ($expr instanceof Expr\Variable && is_string($expr->name)) + || $expr instanceof PropertyFetch + || $expr instanceof Expr\ArrayDimFetch + || $expr instanceof Expr\StaticPropertyFetch + // argument-less instance calls: the shape @phpstan-assert subjects + // take (synthetic per-build nodes, never stored - a walk per + // application otherwise) + || ($expr instanceof Expr\MethodCall && $expr->name instanceof Identifier && !$expr->isFirstClassCallable() && $expr->getArgs() === []) + ) { + return [ + $this->resolveScopeStateType($expr, $this->nativeTypesPromoted), + $this->resolveScopeStateType($expr, true), + ]; + } - return null; - } + $result = $storage->findExpressionResult($expr); + if ($result === null) { + // a call subject (or a synthetic plain-chain variant) is priced on + // demand once per scope: one walk answers both flavours, and the + // truthy and falsey applications of one narrowing - and every later + // ask on this scope - reuse the pair + $key = $this->getNodeKey($expr); + if (array_key_exists($key, $this->pricedSpecifiedExprTypePairs)) { + return $this->pricedSpecifiedExprTypePairs[$key]; + } + + $scope = $this->toMutatingScope(); + $result = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $expr, + $scope, + $storage->duplicate(), + ); - if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { - if (!$this->hasExpressionType($expr)->yes()) { - $nativeReflection = $propertyReflection->getNativeReflection(); - if ( - ($nativeReflection === null || !$nativeReflection->getNativeReflection()->hasDefaultValue()) - && ($nativeReflection === null || !$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) - ) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } + return $this->pricedSpecifiedExprTypePairs[$key] = [ + $result->getTypeOnScope($scope, $scope->nativeTypesPromoted), + $result->getTypeOnScope($scope, true), + ]; + } - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } + // a type tracked for the whole expression on the asking scope wins over + // the stored result's own type: a handler (e.g. isset/empty via + // NonNullabilityHelper) may have processed the inner expression on a + // scope that strips null, so the result's type would be stale for the + // narrowing the caller is applying + return [ + $result->getTypeOnScope($this, $this->nativeTypesPromoted), + $result->getTypeOnScope($this, true), + ]; + } - return null; - } - } - } + /** + * Narrowing counterpart of resolveTypeOfNewWorldHandlerNode() - the old-world + * TypeSpecifier dispatcher asks here for a node's narrowing. Returns null when + * the ExpressionResult carries no specifyTypesCallback - the dispatcher falls + * back to default truthy/falsey narrowing. + * + * @internal + */ + public function specifyTypesOfNewWorldHandlerNode(Expr $node, TypeSpecifierContext $context): SpecifiedTypes + { + return $this->obtainResultForNode($node)->getSpecifiedTypesForScope($this->toMutatingScope(), $context); + } + /** + * Obtains the ExpressionResult of a node so its narrowing/type can be asked + * (getSpecifiedTypesForScope()/getTypeOnScope()): the stored result of an + * already-processed node, or - for a synthetic node (or with no analysis in + * progress) - the result of processing it on demand against a duplicate of + * the current storage, so the throwaway walk never pollutes the live one. + */ + public function obtainResultForNode(Expr $node): ExpressionResult + { + // see resolveTypeOfNewWorldHandlerNode() - rules ask the dispatcher + // with their FiberScope (e.g. ImpossibleCheckTypeHelper), the engine + // side of the boundary works with the mutating flavor + $scope = $this->toMutatingScope(); + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage !== null) { + $result = $storage->findExpressionResult($node); if ($result !== null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheck($expr->var, $typeCallback, $result); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheck($expr->class, $typeCallback, $result); - } - return $result; } - - $result = $typeCallback($propertyReflection->getWritableType()); - if ($result !== null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheck($expr->var, $typeCallback, $result); - } - - if ($expr->class instanceof Expr) { - return $this->issetCheck($expr->class, $typeCallback, $result); - } - } - - return $result; } - if ($result !== null) { - return $result; + if ( + NodeScopeResolver::$guardNewWorld + && isset(NodeScopeResolver::$guardRealExprIds[spl_object_id($node)]) + && !isset(NodeScopeResolver::$guardProcessedExprIds[spl_object_id($node)]) + ) { + throw new ShouldNotHappenException(sprintf( + 'obtainResultForNode() asked about non-synthetic %s on line %d before it was processed by processExprNode() - it should consume the node\'s ExpressionResult instead.', + get_class($node), + $node->getStartLine(), + )); } - return $typeCallback($this->getType($expr)); + // a synthetic node, or no analysis in progress + return $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $node, + $scope, + $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), + ); } - private function issetCheckUndefined(Expr $expr): ?bool + /** + * Makes the storage answer type questions asked on this scope (and every + * scope sharing its ExpressionResultStorageStack) for the duration of an + * analysis. The caller must pop in a finally block. + */ + public function pushExpressionResultStorage(ExpressionResultStorage $storage): void { - if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { - $hasVariable = $this->hasVariableType($expr->name); - if (!$hasVariable->no()) { - return null; - } - - return false; - } - - if ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { - $type = $this->getType($expr->var); - if (!$type->isOffsetAccessible()->yes()) { - return $this->issetCheckUndefined($expr->var); - } - - $dimType = $this->getType($expr->dim); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - - if (!$hasOffsetValue->no()) { - return $this->issetCheckUndefined($expr->var); - } + $this->expressionResultStorageStack->push($storage); + } - return false; - } + public function popExpressionResultStorage(): void + { + $this->expressionResultStorageStack->pop(); + } - if ($expr instanceof Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); + /** + * The ExpressionResultStorage of the analysis currently in progress, the one + * resolveTypeOfNewWorldHandlerNode() prices synthetic nodes against. A handler + * pricing a synthetic node from a lazily-invoked typeCallback must use this + * (not a storage captured at processExpr() time): a later re-evaluation + * (e.g. findEarlyTerminatingExpr()) runs under a different current storage, + * and the captured one would resolve the synthetic node's real subnodes from + * stale stored results. + * + * @internal + */ + /** The settled stored result of the current storage - FiberScope's no-switch fast path. */ + protected function findSettledStoredResult(Expr $node): ?ExpressionResult + { + $storage = $this->expressionResultStorageStack->getCurrent(); + if ($storage === null) { + return null; } - if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } + return $this->container->getByType(NodeScopeResolver::class)->findSettledExpressionResult($storage, $node); + } - return null; + public function getCurrentExpressionResultStorage(): ?ExpressionResultStorage + { + return $this->expressionResultStorageStack->getCurrent(); } /** @api */ @@ -1354,6 +1439,8 @@ public function getKeepVoidType(Expr $node): Type { if ( !$node instanceof Match_ + && !$node instanceof Expr\Yield_ + && !$node instanceof Expr\YieldFrom && ( ( !$node instanceof FuncCall @@ -1363,18 +1450,30 @@ public function getKeepVoidType(Expr $node): Type ) || $node->isFirstClassCallable() ) ) { - return $this->getType($node); + return $this->getScopeStateType($node); } - $originalType = $this->getType($node); + $originalType = $this->getScopeStateType($node); if (!TypeCombinator::containsNull($originalType)) { return $originalType; } - $clonedNode = clone $node; - $clonedNode->setAttribute(self::KEEP_VOID_ATTRIBUTE_NAME, true); + // the null may be a projected void: read the call's/match's raw + // (void-kept) own type. A result already stored in the current frame is + // read directly; a node evaluated on a different scope - e.g. an arrow + // body typed on the closure scope - is processed on demand there, its + // raw own type keeping void without any keep-void marker on the node. + $storage = $this->expressionResultStorageStack->getCurrent(); + $result = $storage !== null ? $storage->findExpressionResult($node) : null; + if ($result === null) { + $result = $this->container->getByType(NodeScopeResolver::class)->processExprOnDemand( + $node, + $this->toMutatingScope(), + $storage !== null ? $storage->duplicate() : new ExpressionResultStorage(), + ); + } - return $this->getType($clonedNode); + return $result->getKeepVoidType($this->nativeTypesPromoted); } public function doNotTreatPhpDocTypesAsCertain(): self @@ -1484,6 +1583,19 @@ public function hasExpressionType(Expr $node): TrinaryLogic return ScopeOps::hasExpressionType($this, $node, $this->exprPrinter); } + /** + * Reads the type tracked for an expression straight from its holder, skipping + * the extension/dispatch/cache machinery that getType() runs. Only valid when + * hasExpressionType($node) is yes - mirrors resolveType()'s tracked-holder + * early return and is what ExpressionResult uses on its tracked-holder path. + * + * @internal + */ + public function getTrackedExpressionType(Expr $node): Type + { + return $this->expressionTypes[$this->getNodeKey($node)]->getType(); + } + /** * @param MethodReflection|FunctionReflection|null $reflection */ @@ -1846,7 +1958,7 @@ private function getRealParameterDefaultValues(Node\FunctionLike $functionLike): if (!$parameter->var instanceof Variable || !is_string($parameter->var->name)) { throw new ShouldNotHappenException(); } - $realParameterDefaultValues[$parameter->var->name] = $this->getType($parameter->default); + $realParameterDefaultValues[$parameter->var->name] = $this->initializerExprTypeResolver->getType($parameter->default, InitializerExprContext::fromScope($this)); } return $realParameterDefaultValues; @@ -2384,7 +2496,7 @@ private function expressionTypeIsUnchangeable(ExpressionTypeHolder $typeHolder): true, ) && isset($expr->getArgs()[0]) - && count($this->getType($expr->getArgs()[0]->value)->getConstantStrings()) === 1 + && count($this->getScopeStateType($expr->getArgs()[0]->value)->getConstantStrings()) === 1 && $type->isTrue()->yes(); } @@ -2648,6 +2760,10 @@ public function enterForeach(self $originalScope, Expr $iteratee, Type $iteratee // ($type = 'foo' invalidates this expression, same as OriginalForeachKeyExpr). $scope = $scope->assignExpression(new OriginalForeachValueExpr($valueName), $valueType, $nativeValueType); if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { + // the write-through rebuilds the iteratee AT FOREACH ENTRY with the + // value variable's latest type - captured here, not read live: a + // live read would union the transient mid-iteration value states + // into the array (the loop convergence owns cross-iteration merging) $scope = $scope->assignExpression( new IntertwinedVariableByReferenceWithExpr($valueName, $iteratee, new SetExistingOffsetValueTypeExpr( new NativeTypeExpr($iterateeType, $nativeIterateeType), @@ -2740,8 +2856,6 @@ public function enterExpressionAssign(Expr $expr, bool $isPlainWrite = true): se $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2771,8 +2885,6 @@ public function exitExpressionAssign(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2832,8 +2944,6 @@ public function setAllowedUndefinedExpression(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2863,8 +2973,6 @@ public function unsetAllowedUndefinedExpression(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -3042,10 +3150,10 @@ private function unsetExpression(Expr $expr): self { $scope = $this; if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { - $exprVarType = $scope->getType($expr->var); + $exprVarType = $scope->getScopeStateType($expr->var); $dimType = $scope->getType($expr->dim); $unsetType = $exprVarType->unsetOffset($dimType); - $exprVarNativeType = $scope->getNativeType($expr->var); + $exprVarNativeType = $scope->getScopeStateNativeType($expr->var); $dimNativeType = $scope->getNativeType($expr->dim); $unsetNativeType = $exprVarNativeType->unsetOffset($dimNativeType); $scope = $scope->assignExpression($expr->var, $unsetType, $unsetNativeType)->invalidateExpression( @@ -3063,11 +3171,11 @@ private function unsetExpression(Expr $expr): self $expr->var->var, $this->getType($expr->var->var)->setOffsetValueType( $scope->getType($expr->var->dim), - $scope->getType($expr->var), + $scope->getScopeStateType($expr->var), ), $this->getNativeType($expr->var->var)->setOffsetValueType( $scope->getNativeType($expr->var->dim), - $scope->getNativeType($expr->var), + $scope->getScopeStateNativeType($expr->var), ), ); } @@ -3076,6 +3184,121 @@ private function unsetExpression(Expr $expr): self return $scope->invalidateExpression($expr); } + /** + * A narrowable expression's current type as this scope sees it, derived + * from tracked state (recursing into operands via reflection/offset reads) + * - never by processing the node. The flavour follows the scope: a + * native-promoted scope answers native types. Non-narrowable expressions + * (calls, constants) fall back to getType(). + */ + public function getStateType(Expr $expr): Type + { + return $this->resolveScopeStateType($expr, $this->nativeTypesPromoted); + } + + private function getScopeStateType(Expr $expr): Type + { + return $this->resolveScopeStateType($expr, false); + } + + private function getScopeStateNativeType(Expr $expr): Type + { + return $this->resolveScopeStateType($expr, true); + } + + /** + * Reads a narrowable expression's current type from the scope's tracked + * state (recursing into its operands), instead of routing through the stored + * ExpressionResult callbacks - so it reflects narrowings and assignments + * applied to this scope rather than the expression's original evaluation + * point (where Variable callbacks would read their captured beforeScope). + */ + private function resolveScopeStateType(Expr $expr, bool $native): Type + { + if (!$expr instanceof Variable && $this->hasExpressionType($expr)->yes()) { + return $native ? $this->getNativeType($expr) : $this->getType($expr); + } + + if ($expr instanceof Variable && is_string($expr->name)) { + $scope = $native ? $this->doNotTreatPhpDocTypesAsCertain() : $this; + + return $scope->hasVariableType($expr->name)->no() ? new ErrorType() : $scope->getVariableType($expr->name); + } + + if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) { + $varStateType = $this->resolveScopeStateType($expr->var, $native); + if ($varStateType instanceof NeverType) { + // real pricing of an offset read on never yields ErrorType (a + // benevolent mixed), never NeverType - mirror it, or a narrowing + // applied in a dead branch intersects its type against never and + // loses it (e.g. is_object($x[0]) no longer tracks $x[0] as object, + // silencing rules that read the narrowed type) + return new ErrorType(); + } + + return $varStateType->getOffsetValueType($this->resolveScopeStateType($expr->dim, $native)); + } + + if ($expr instanceof PropertyFetch && $expr->name instanceof Identifier) { + $propertyReflection = $this->getInstancePropertyReflection( + $this->resolveScopeStateType($expr->var, $native), + $expr->name->toString(), + ); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if ($native) { + return $propertyReflection->hasNativeType() ? $propertyReflection->getNativeType() : new MixedType(); + } + + return $propertyReflection->getReadableType(); + } + + if ($expr instanceof Expr\StaticPropertyFetch && $expr->name instanceof Node\VarLikeIdentifier) { + $fetchedOnType = $expr->class instanceof Name + ? $this->resolveTypeByName($expr->class) + : TypeCombinator::removeNull($this->resolveScopeStateType($expr->class, $native))->getObjectTypeOrClassStringObjectType(); + $propertyReflection = $this->getStaticPropertyReflection($fetchedOnType, $expr->name->toString()); + if ($propertyReflection === null) { + return new ErrorType(); + } + + if ($native) { + return $propertyReflection->hasNativeType() ? $propertyReflection->getNativeType() : new MixedType(); + } + + return $propertyReflection->getReadableType(); + } + + // an argument-less instance call - the shape @phpstan-assert subjects + // take (synthetic nodes built fresh from the assert tag, never stored): + // its declared return type on the receiver's state is the narrowing + // base, derived from reflection instead of walking the synthetic node + if ( + $expr instanceof Expr\MethodCall + && $expr->name instanceof Identifier + && !$expr->isFirstClassCallable() + && $expr->getArgs() === [] + ) { + $methodReflection = $this->getMethodReflection( + $this->resolveScopeStateType($expr->var, $native), + $expr->name->toString(), + ); + if ($methodReflection === null) { + return new ErrorType(); + } + + $variant = ParametersAcceptorSelector::combineAcceptors($methodReflection->getVariants()); + + return $native ? $variant->getNativeReturnType() : $variant->getReturnType(); + } + + // genuinely non-narrowed expressions (constants, calls, ...) have no + // variable-callback hazard, so read them normally. + return $native ? $this->getNativeType($expr) : $this->getType($expr); + } + public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): self { if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { @@ -3091,17 +3314,23 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, /** An unpublished copy of this scope that in-place specification may mutate. */ private function openSpecificationScope(): self { - /** @var static */ - return ScopeOps::scopeWith( - $this, + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), $this->expressionTypes, $this->nativeExpressionTypes, $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, $this->currentlyAssignedExpressions, $this->currentlyAllowedUndefinedExpressions, $this->inFunctionCallsStack, - $this->inFirstLevelStatement, $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, ); } @@ -3152,9 +3381,9 @@ private function specifyExpressionTypeInPlace(Expr $expr, Type $type, Type $nati && !$expr->dim instanceof Expr\PostDec && !$expr->dim instanceof Expr\PostInc ) { - $dimType = $this->getType($expr->dim)->toArrayKey(); + $dimType = $this->getScopeStateType($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { - $exprVarType = $this->getType($expr->var); + $exprVarType = $this->getScopeStateType($expr->var); $isArray = $exprVarType->isArray(); if (!$exprVarType instanceof MixedType && !$isArray->no()) { $varType = $exprVarType; @@ -3178,7 +3407,7 @@ private function specifyExpressionTypeInPlace(Expr $expr, Type $type, Type $nati $this->specifyExpressionTypeInPlace( $expr->var, $varType, - $this->getNativeType($expr->var), + $this->getScopeStateNativeType($expr->var), $certainty, ); } @@ -3192,15 +3421,6 @@ private function specifyExpressionTypeInPlace(Expr $expr, Type $type, Type $nati $exprString = $this->getNodeKey($expr); $this->expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); $this->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); - // the writes invalidate every lazily-derived view of this scope; reset - // them to their fresh-constructor defaults, exactly as deriving a new - // scope per specification did - $this->resolvedTypes = []; - $this->truthyScopes = []; - $this->falseyScopes = []; - $this->fiberScope = null; - $this->scopeOutOfFirstLevelStatement = null; - $this->scopeWithPromotedNativeTypes = null; if (!($expr instanceof AlwaysRememberedExpr)) { return; @@ -3393,12 +3613,12 @@ private function isComplexUnionType(Type $type): bool public function addTypeToExpression(Expr $expr, Type $type): self { - $originalExprType = $this->getType($expr); + $originalExprType = $this->getScopeStateType($expr); if ($this->isComplexUnionType($originalExprType)) { return $this; } - $nativeType = $this->getNativeType($expr); + $nativeType = $this->getScopeStateNativeType($expr); if ($originalExprType->equals($nativeType)) { $newType = TypeCombinator::intersect($type, $originalExprType); @@ -3419,7 +3639,7 @@ public function removeTypeFromExpression(Expr $expr, Type $typeToRemove): self return $this; } - $exprType = $this->getType($expr); + $exprType = $this->getScopeStateType($expr); if ($exprType instanceof NeverType) { return $this; } @@ -3431,7 +3651,7 @@ public function removeTypeFromExpression(Expr $expr, Type $typeToRemove): self return $this->specifyExpressionType( $expr, TypeCombinator::remove($exprType, $typeToRemove), - TypeCombinator::remove($this->getNativeType($expr), $typeToRemove), + TypeCombinator::remove($this->getScopeStateNativeType($expr), $typeToRemove), TrinaryLogic::createYes(), ); } @@ -3441,16 +3661,9 @@ public function removeTypeFromExpression(Expr $expr, Type $typeToRemove): self */ public function filterByTruthyValue(Expr $expr): self { - $exprString = $this->getNodeKey($expr); - if (array_key_exists($exprString, $this->truthyScopes)) { - return $this->truthyScopes[$exprString]; - } - $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createTruthy()); - $scope = $this->applySpecifiedTypes($specifiedTypes); - $this->truthyScopes[$exprString] = $scope; - return $scope; + return $this->applySpecifiedTypes($specifiedTypes); } /** @@ -3458,21 +3671,19 @@ public function filterByTruthyValue(Expr $expr): self */ public function filterByFalseyValue(Expr $expr): self { - $exprString = $this->getNodeKey($expr); - if (array_key_exists($exprString, $this->falseyScopes)) { - return $this->falseyScopes[$exprString]; - } - $specifiedTypes = $this->typeSpecifier->specifyTypesInCondition($this, $expr, TypeSpecifierContext::createFalsey()); - $scope = $this->applySpecifiedTypes($specifiedTypes); - $this->falseyScopes[$exprString] = $scope; - return $scope; + return $this->applySpecifiedTypes($specifiedTypes); } /** * Applies computed narrowing to this scope. * + * The types inside SpecifiedTypes were already computed from ExpressionResults + * by the specifyTypesCallback of an ExprHandler. This method must never call + * Scope::getType() - it only combines the given types with already-tracked + * expression type holders. + * * @return static */ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self @@ -3495,34 +3706,34 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $typeSpecifications = []; foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { - if ($expr instanceof Node\Scalar || $expr instanceof Expr\Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { continue; } $typeSpecifications[] = [ 'sure' => true, - 'exprString' => $exprString, + 'exprString' => (string) $exprString, 'expr' => $expr, 'type' => $type, ]; } foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { - if ($expr instanceof Node\Scalar || $expr instanceof Expr\Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { continue; } $typeSpecifications[] = [ 'sure' => false, - 'exprString' => $exprString, + 'exprString' => (string) $exprString, 'expr' => $expr, 'type' => $type, ]; } foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$expr, $terms]) { - if ($expr instanceof Node\Scalar || $expr instanceof Expr\Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { continue; } $typeSpecifications[] = [ 'sure' => true, - 'exprString' => $exprString, + 'exprString' => (string) $exprString, 'expr' => $expr, 'terms' => $terms, ]; @@ -3545,6 +3756,7 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions = []; foreach ($typeSpecifications as $typeSpecification) { $expr = $typeSpecification['expr']; + $exprString = $typeSpecification['exprString']; if ($expr instanceof IssetExpr) { $issetExpr = $expr; @@ -3574,35 +3786,75 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } + // only Yes-certainty holders hold the current type of the expression - + // a Maybe-certainty holder holds the when-defined type (e.g. after + // merging a branch where the expression was never assigned), which + // the certainty-aware Scope::getType() of the old world never returned + $trackedType = null; + $trackedNativeType = null; + if ( + array_key_exists($exprString, $scope->expressionTypes) + && $scope->expressionTypes[$exprString]->getCertainty()->yes() + ) { + $trackedType = $scope->expressionTypes[$exprString]->getType(); + } + if ( + array_key_exists($exprString, $scope->nativeExpressionTypes) + && $scope->nativeExpressionTypes[$exprString]->getCertainty()->yes() + ) { + $trackedNativeType = $scope->nativeExpressionTypes[$exprString]->getType(); + } + if ($trackedType === null) { + $currentTypes = $scope->getCurrentTypesOfSpecifiedExpr($expr); + if ($currentTypes !== null) { + if ($scope->isComplexUnionType($currentTypes[0])) { + continue; + } + + $trackedType = $currentTypes[0]; + $trackedNativeType ??= $currentTypes[1]; + } + } + if (isset($typeSpecification['terms'])) { // an alternative-form entry: the union over its terms of // `(sure ?? current) minus subtract`, evaluated here at the // application point - the deferred descendant of the old // SpecifiedTypes::normalize() - $evaluate = static function (Type $current) use ($typeSpecification): Type { + $evaluate = static function (?Type $current) use ($typeSpecification): ?Type { $parts = []; foreach ($typeSpecification['terms'] as [$sure, $subtract]) { $base = $sure ?? $current; + if ($base === null) { + return null; + } $parts[] = $subtract !== null ? TypeCombinator::remove($base, $subtract) : $base; } return TypeCombinator::union(...$parts); }; - $originalExprType = $scope->getType($expr); - if (!$scope->isComplexUnionType($originalExprType)) { - $nativeType = $scope->getNativeType($expr); - $newType = TypeCombinator::intersect($evaluate($originalExprType), $originalExprType); - $newNativeType = TypeCombinator::intersect($evaluate($nativeType), $nativeType); - if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { - if (!$scopeIsWorkingCopy) { - $scope = $scope->openSpecificationScope(); - $scopeIsWorkingCopy = true; - } - $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + $evaluated = $evaluate($trackedType); + if ($evaluated === null) { + // a current-type-dependent term with no known current type - + // nothing sound to specify (mirrors the sure-not behaviour) + continue; + } + $evaluatedNative = $evaluate($trackedNativeType ?? $trackedType) ?? $evaluated; + + $newType = $trackedType !== null ? TypeCombinator::intersect($evaluated, $trackedType) : $evaluated; + $newNativeType = $trackedNativeType !== null ? TypeCombinator::intersect($evaluatedNative, $trackedNativeType) : $evaluatedNative; + if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; } - $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); } + $holderType = array_key_exists($exprString, $scope->expressionTypes) + ? $scope->expressionTypes[$exprString]->getType() + : $newType; + $specifiedExpressions[$exprString] = ExpressionTypeHolder::createYes($expr, $holderType); continue; } @@ -3612,27 +3864,8 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $scope = $scope->assignExpression($expr, $type, $type); $scopeIsWorkingCopy = false; } else { - // addTypeToExpression(), writing into the working copy - $originalExprType = $scope->getType($expr); - if (!$scope->isComplexUnionType($originalExprType)) { - $nativeType = $scope->getNativeType($expr); - $newType = TypeCombinator::intersect($type, $originalExprType); - $newNativeType = $originalExprType->equals($nativeType) ? $newType : TypeCombinator::intersect($type, $nativeType); - if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { - if (!$scopeIsWorkingCopy) { - $scope = $scope->openSpecificationScope(); - $scopeIsWorkingCopy = true; - } - $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); - } - } - } - } elseif (!$type instanceof NeverType) { - // removeTypeFromExpression(), writing into the working copy - $exprType = $scope->getType($expr); - if (!$exprType instanceof NeverType && !$scope->isComplexUnionType($exprType)) { - $newType = TypeCombinator::remove($exprType, $type); - $newNativeType = TypeCombinator::remove($scope->getNativeType($expr), $type); + $newType = $trackedType !== null ? TypeCombinator::intersect($type, $trackedType) : $type; + $newNativeType = $trackedNativeType !== null ? TypeCombinator::intersect($type, $trackedNativeType) : $type; if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { if (!$scopeIsWorkingCopy) { $scope = $scope->openSpecificationScope(); @@ -3641,8 +3874,29 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); } } + } else { + if ($type instanceof NeverType || $trackedType instanceof NeverType) { + continue; + } + $newType = $trackedType !== null ? TypeCombinator::remove($trackedType, $type) : null; + if ($newType === null) { + // the expression is not tracked - there is nothing to subtract from + continue; + } + $newNativeType = $trackedNativeType !== null ? TypeCombinator::remove($trackedNativeType, $type) : $newType; + if (!$this->isSpecifyExpressionTypeNoop($expr, $newType)) { + if (!$scopeIsWorkingCopy) { + $scope = $scope->openSpecificationScope(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } } - $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); + + $holderType = array_key_exists($exprString, $scope->expressionTypes) + ? $scope->expressionTypes[$exprString]->getType() + : $type; + $specifiedExpressions[$exprString] = ExpressionTypeHolder::createYes($expr, $holderType); } $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); @@ -3651,24 +3905,31 @@ public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self foreach ($specifiedTypes->getConditionalExpressionHolderRecipes() as $recipe) { // the recipes' state-dependent math runs here, against this scope's // pre-application state - the application point of the narrowing - foreach ($recipe->evaluate($this) as $recipeExprString => $recipeHolders) { + foreach ($recipe->evaluate($this) as $exprString => $recipeHolders) { foreach ($recipeHolders as $key => $holder) { - $newConditionalExpressionHolders[$recipeExprString][$key] = $holder; + $newConditionalExpressionHolders[$exprString][$key] = $holder; } } } /** @var static */ - return ScopeOps::scopeWith( - $scope, + return $scope->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), $scope->expressionTypes, $scope->nativeExpressionTypes, $this->mergeConditionalExpressions($newConditionalExpressionHolders, $scope->conditionalExpressions), + $scope->inClosureBindScopeClasses, + $scope->anonymousFunctionReflection, + $scope->inFirstLevelStatement, $scope->currentlyAssignedExpressions, $scope->currentlyAllowedUndefinedExpressions, $scope->inFunctionCallsStack, - $scope->inFirstLevelStatement, $scope->afterExtractCall, + $scope->parentScope, + $scope->nativeTypesPromoted, ); } @@ -3775,8 +4036,6 @@ public function exitFirstLevelStatements(): self $this->afterExtractCall, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; $this->scopeOutOfFirstLevelStatement = $scope; return $scope; @@ -4898,7 +5157,8 @@ public function getInstancePropertyReflection(Type $typeWithProperty, string $pr if ($typeWithProperty instanceof NeverType) { return null; } - } elseif (!$typeWithProperty->hasInstanceProperty($propertyName)->yes()) { + } + if (!$typeWithProperty->hasInstanceProperty($propertyName)->yes()) { return null; } @@ -4913,7 +5173,8 @@ public function getStaticPropertyReflection(Type $typeWithProperty, string $prop if ($typeWithProperty instanceof NeverType) { return null; } - } elseif (!$typeWithProperty->hasStaticProperty($propertyName)->yes()) { + } + if (!$typeWithProperty->hasStaticProperty($propertyName)->yes()) { return null; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 53ac5a06f71..a99a00e1689 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -50,8 +50,10 @@ use PhpParser\NodeTraverser; use PHPStan\Analyser\ExprHandler\AssignHandler; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; +use PHPStan\Analyser\ExprHandler\Helper\IdenticalNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -144,9 +146,13 @@ use PHPStan\Rules\Properties\ReadWritePropertiesExtension; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\BooleanType; use PHPStan\Type\ClosureType; +use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; +use PHPStan\Type\ErrorType; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\FunctionParameterClosureThisExtension; use PHPStan\Type\FunctionParameterClosureTypeExtension; @@ -186,11 +192,14 @@ use function array_slice; use function array_values; use function count; +use function get_class; +use function getenv; use function in_array; use function is_array; use function is_int; use function is_string; use function max; +use function spl_object_id; use function sprintf; use function strtolower; use function trim; @@ -215,6 +224,52 @@ class NodeScopeResolver /** @var array */ private array $calledMethodResults = []; + /** + * When processing a synthetic node on demand (for a Fiber request), real AST + * nodes contained in it were already processed and must not be processed again. + */ + protected bool $returnStoredExpressionResults = false; + + /** + * Consume-stored mode: a walk that deliberately re-enters an + * already-walked subtree (the nullsafe plain twin re-walking its + * receiver) consumes stored results unconditionally instead of + * re-processing - node callbacks fired during the original walk. + */ + private bool $consumeStoredExpressionResults = false; + + /** + * spl_object_id => recursion depth of the expressions currently being + * processed by processExprNode. A fiber pending on one of them must not be + * flushed at a nested statement-list boundary inside that expression - it + * is resumed when the expression's own processing stores its result. + * + * @var array + */ + protected array $processingExprIds = []; + + /** Whether the PHPSTAN_GUARD_NW diagnostic is enabled (cached from the env). */ + public static bool $guardNewWorld = false; + + /** + * spl_object_id => true of every Expr in the file's parsed AST. Populated + * only when the PHPSTAN_GUARD_NW diagnostic is enabled, so the guards can + * tell a real AST node from a node a rule built during analysis (which + * legitimately resolves on demand). Static so MutatingScope can read it. + * + * @var array + */ + public static array $guardRealExprIds = []; + + /** + * spl_object_id => true of every Expr already processed by processExprNode + * in the current file. Used by the MutatingScope::getType guard to detect a + * real AST node whose type is asked before it was processed. + * + * @var array + */ + public static array $guardProcessedExprIds = []; + /** * @param ExtensionsCollection $functionParameterOutTypeExtensions * @param ExtensionsCollection $methodParameterOutTypeExtensions @@ -245,7 +300,6 @@ public function __construct( private readonly FileTypeMapper $fileTypeMapper, private readonly PhpDocInheritanceResolver $phpDocInheritanceResolver, private readonly FileHelper $fileHelper, - private readonly TypeSpecifier $typeSpecifier, #[AutowiredExtensions(of: ReadWritePropertiesExtension::class)] private readonly ExtensionsCollection $readWritePropertiesExtensions, #[AutowiredExtensions(of: FunctionParameterClosureThisExtension::class)] @@ -274,9 +328,10 @@ public function __construct( #[AutowiredParameter] private readonly bool $treatPhpDocTypesAsCertain, private readonly ImplicitToStringCallHelper $implicitToStringCallHelper, - protected readonly ExpressionResultFactory $expressionResultFactory, + private readonly ExpressionResultFactory $expressionResultFactory, ) { + self::$guardNewWorld = getenv('PHPSTAN_GUARD_NW') === '1'; } /** @@ -311,9 +366,36 @@ public function processNodes( callable $nodeCallback, ): void { + if (self::$guardNewWorld) { + self::$guardRealExprIds = []; + self::$guardProcessedExprIds = []; + foreach ((new NodeFinder())->findInstanceOf($nodes, Expr::class) as $realExpr) { + self::$guardRealExprIds[spl_object_id($realExpr)] = true; + } + } + $this->resetPerFileAnalysisState(); $expressionResultStorage = new ExpressionResultStorage(); + $scope->pushExpressionResultStorage($expressionResultStorage); + try { + $this->processNodesWithStorage($nodes, $scope, $expressionResultStorage, $nodeCallback); + } finally { + $scope->popExpressionResultStorage(); + } + } + + /** + * @param Node[] $nodes + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processNodesWithStorage( + array $nodes, + MutatingScope $scope, + ExpressionResultStorage $expressionResultStorage, + callable $nodeCallback, + ): void + { $alreadyTerminated = false; $exitPoints = []; @@ -391,14 +473,60 @@ public function processNodes( $this->processPendingFibers($expressionResultStorage); } + /** The stored result an outside asker may consume. */ + public function findSettledExpressionResult(ExpressionResultStorage $storage, Expr $expr): ?ExpressionResult + { + return $storage->findExpressionResult($expr); + } + + /** An effect-free result carrying eagerly known types, positioned at the given scope. */ + protected function createEagerExpressionResult(MutatingScope $scope, Expr $expr, Type $type, Type $nativeType): ExpressionResult + { + return $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + type: $type, + nativeType: $nativeType, + ); + } + public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { + if (self::$guardNewWorld) { + self::$guardProcessedExprIds[spl_object_id($expr)] = true; + } + // handlers are answered from stored results in both worlds - storing must + // not depend on fibers + $storage->storeExpressionResult($expr, $expressionResult); } protected function processPendingFibers(ExpressionResultStorage $storage): void { } + /** + * @param Node\Stmt[] $bodyStmts + * @param Closure(string): bool $gotoNameMatcher + */ + /** + * Narrows a scope by a (often synthetic) control-flow condition the new-world + * way: resolve its narrowing through the scope's on-demand dispatcher and apply + * it via applySpecifiedTypes, instead of the old-world filterBy*Value(). + */ + private function narrowScopeWithCondition(MutatingScope $scope, Expr $expr, TypeSpecifierContext $context): MutatingScope + { + $specifiedTypes = $scope->specifyTypesOfNewWorldHandlerNode($expr, $context); + + return $scope->applySpecifiedTypes($specifiedTypes); + } + /** * @param Node\Stmt[] $bodyStmts * @param Closure(string): bool $gotoNameMatcher @@ -423,8 +551,7 @@ private function resolveBackwardGotoScope( } if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is - // skipped + // reproduces the previous pass's exit, so the verification walk is skipped $bodyScope = $prevScope; break; } @@ -538,14 +665,19 @@ public function processStmtNodes( ): StatementResult { $storage = new ExpressionResultStorage(); - return $this->processStmtNodesInternal( - $parentNode, - $stmts, - $scope, - $storage, - $nodeCallback, - $context, - )->toPublic(); + $scope->pushExpressionResultStorage($storage); + try { + return $this->processStmtNodesInternal( + $parentNode, + $stmts, + $scope, + $storage, + $nodeCallback, + $context, + )->toPublic(); + } finally { + $scope->popExpressionResultStorage(); + } } /** @@ -569,16 +701,27 @@ private function processStmtNodesInternal( $nodeCallback, $context, ); - $this->processPendingFibers($storage); + // Flush pending fibers only at a scope boundary - a function/method body, + // a class/trait body, a namespace. Nested control-flow statement lists + // (if/else branches, loop and switch/try bodies) must NOT flush: a rule + // invoked at the scope's entry node (e.g. UnusedConstructorParametersRule + // on InClassMethodNode) asks the types of expressions appearing later in + // the body, and a flush at an earlier branch would resolve those fibers + // on the asker's scope before natural traversal stores the results. Such + // fibers are resumed when their expression stores its result, or at this + // scope boundary once the whole body is processed. + if ( + $parentNode instanceof Node\FunctionLike + || $parentNode instanceof Node\Stmt\ClassLike + || $parentNode instanceof Node\Stmt\Namespace_ + ) { + $this->processPendingFibers($storage); + } return $statementResult; } /** - * The statement-list walk without the per-list pending-fiber flush - for - * the callers that sit inside an enclosing statement walk (nested - * control-flow lists, convergence passes) whose own boundary flushes. - * * @param Node\Stmt[] $stmts * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -591,7 +734,23 @@ private function processStmtNodesInternalWithoutFlushingPendingFibers( StatementContext $context, ): InternalStatementResult { - return $this->doProcessStmtNodes($parentNode, $stmts, $scope, $storage, $nodeCallback, $context); + // make the storage this walk writes into scope-visible: loop-convergence + // passes (including the closure by-ref convergence, which calls this + // method directly) thread a throwaway duplicate that would otherwise + // never reach the storage stack, so every in-pass ask + // (applySpecifiedTypes pricing, rules via Scope::getType) would miss the + // pass's own results and re-process real nodes on demand + $pushStorage = $scope->getCurrentExpressionResultStorage() !== $storage; + if ($pushStorage) { + $scope->pushExpressionResultStorage($storage); + } + try { + return $this->doProcessStmtNodes($parentNode, $stmts, $scope, $storage, $nodeCallback, $context); + } finally { + if ($pushStorage) { + $scope->popExpressionResultStorage(); + } + } } /** @@ -876,48 +1035,57 @@ public function processStmtNode( $gatheredYieldStatements = []; $executionEnds = []; $functionImpurePoints = []; - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $storage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { - if ($scope->getFunction() !== $functionScope->getFunction()) { - return; - } - if ($scope->isInAnonymousFunction()) { - return; - } - if ($node instanceof PropertyAssignNode) { - $functionImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; - } + // the body's results live in a per-body storage released right after + // the FunctionReturnStatementsNode rules ran - see the ClassMethod + // branch for the reasoning + $bodyStorage = $storage->duplicate(); + $scope->pushExpressionResultStorage($bodyStorage); + try { + $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $bodyStorage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { + if ($scope->getFunction() !== $functionScope->getFunction()) { + return; + } + if ($scope->isInAnonymousFunction()) { + return; + } + if ($node instanceof PropertyAssignNode) { + $functionImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if (!$node instanceof Return_) { + return; + } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }, $nodeCallback), StatementContext::createTopLevel())->toPublic(); + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }, $nodeCallback), StatementContext::createTopLevel())->toPublic(); - $this->callNodeCallback($nodeCallback, new FunctionReturnStatementsNode( - $stmt, - $gatheredReturnStatements, - $gatheredYieldStatements, - $statementResult, - $executionEnds, - array_merge($statementResult->getImpurePoints(), $functionImpurePoints), - $functionReflection, - ), $functionScope, $storage); - if (!$scope->isInAnonymousFunction()) { - $this->processPendingFibers($storage); + $this->callNodeCallback($nodeCallback, new FunctionReturnStatementsNode( + $stmt, + $gatheredReturnStatements, + $gatheredYieldStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $functionImpurePoints), + $functionReflection, + ), $functionScope, $bodyStorage); + if (!$scope->isInAnonymousFunction()) { + $this->processPendingFibers($bodyStorage); + } + } finally { + $scope->popExpressionResultStorage(); } // declaring the function defines it in global state, so a negative @@ -1030,61 +1198,81 @@ public function processStmtNode( $gatheredYieldStatements = []; $executionEnds = []; $methodImpurePoints = []; - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $storage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { - if ($scope->getFunction() !== $methodScope->getFunction()) { - return; - } - if ($scope->isInAnonymousFunction()) { - return; - } - if ($node instanceof PropertyAssignNode) { - if ( - $node->getPropertyFetch() instanceof Expr\PropertyFetch - && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection - && $scope->getFunction()->getDeclaringClass()->hasConstructor() - && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() - && TypeUtils::findThisType($scope->getType($node->getPropertyFetch()->var)) !== null - ) { + // the body's results live in a per-body storage released right + // after the MethodReturnStatementsNode rules ran: later asks about + // body expressions (e.g. class-level rules pricing gathered nodes) + // go through the on-demand bridge, so keeping the results for the + // rest of the file would only pin the body's whole result graph + // (callbacks, scopes, types) at no benefit + $bodyStorage = $storage->duplicate(); + $scope->pushExpressionResultStorage($bodyStorage); + try { + $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $bodyStorage, new GatheringNodeCallback(function (Node $node, Scope $scope) use ($methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { + if ($scope->getFunction() !== $methodScope->getFunction()) { + return; + } + if ($scope->isInAnonymousFunction()) { + return; + } + if ($node instanceof PropertyAssignNode) { + if ( + $node->getPropertyFetch() instanceof Expr\PropertyFetch + && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection + && $scope->getFunction()->getDeclaringClass()->hasConstructor() + && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() + && TypeUtils::findThisType($this->readScopeStateOrSyntheticType($node->getPropertyFetch()->var, $scope->toMutatingScope())) !== null + ) { + return; + } + $methodImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + return; + } + if ($node instanceof ExecutionEndNode) { + $executionEnds[] = $node; + return; + } + if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { + $gatheredYieldStatements[] = $node; + } + if (!$node instanceof Return_) { return; } - $methodImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - return; - } - if ($node instanceof ExecutionEndNode) { - $executionEnds[] = $node; - return; - } - if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { - $gatheredYieldStatements[] = $node; - } - if (!$node instanceof Return_) { - return; - } - $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }, $nodeCallback), StatementContext::createTopLevel())->toPublic(); + $gatheredReturnStatements[] = new ReturnStatement($scope, $node); + }, $nodeCallback), StatementContext::createTopLevel())->toPublic(); - $methodReflection = $methodScope->getFunction(); - if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { - throw new ShouldNotHappenException(); - } + $methodReflection = $methodScope->getFunction(); + if (!$methodReflection instanceof PhpMethodFromParserNodeReflection) { + throw new ShouldNotHappenException(); + } - $this->callNodeCallback($nodeCallback, new MethodReturnStatementsNode( - $stmt, - $gatheredReturnStatements, - $gatheredYieldStatements, - $statementResult, - $executionEnds, - array_merge($statementResult->getImpurePoints(), $methodImpurePoints), - $classReflection, - $methodReflection, - ), $methodScope, $storage); + $this->callNodeCallback($nodeCallback, new MethodReturnStatementsNode( + $stmt, + $gatheredReturnStatements, + $gatheredYieldStatements, + $statementResult, + $executionEnds, + array_merge($statementResult->getImpurePoints(), $methodImpurePoints), + $classReflection, + $methodReflection, + ), $methodScope, $bodyStorage); + // flush the fibers the MethodReturnStatementsNode rules parked + // on $bodyStorage (synthetic-node type asks) before the storage + // is dropped - mirrors the FunctionReturnStatementsNode flush; a + // dropped fiber silently loses the asking rule's errors and + // every later rule in the same callback batch + if (!$scope->isInAnonymousFunction()) { + $this->processPendingFibers($bodyStorage); + } + } finally { + $scope->popExpressionResultStorage(); + } if ($isConstructor) { $finalScope = null; @@ -1130,7 +1318,7 @@ public function processStmtNode( $result = $this->processExprNode($stmt, $echoExpr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); - $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope); + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($echoExpr, $scope, $result); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $result->getScope(); @@ -1214,11 +1402,9 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new NoopExpressionNode($stmt->expr, $hasAssign), $scope, $storage); } $scope = $result->getScope(); - $scope = $scope->applySpecifiedTypes($this->typeSpecifier->specifyTypesInCondition( - $scope, - $stmt->expr, - TypeSpecifierContext::createNull(), - )); + // the expression statement was just processed; read its narrowing from + // the result instead of re-resolving it via specifyTypesInCondition(). + $scope = $scope->applySpecifiedTypes($result->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createNull())); $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); @@ -1227,10 +1413,7 @@ public function processStmtNode( // The expression statement is an exit point when its value type is an // explicit never: exit/die/throw, a never-returning call, or a call // configured as early-terminating (the call handlers give those never). - // Asked on the pre-statement scope: a conditional return type must - // resolve against the argument types the call was made with, not - // against state the statement itself just changed (bug-11565). - $statementType = $currentScope->getType($stmt->expr); + $statementType = $result->getType(); if ($statementType instanceof NeverType && $statementType->isExplicit()) { return new InternalStatementResult($scope, $hasYield, true, [ new InternalStatementExitPoint($stmt, $scope), @@ -1510,10 +1693,20 @@ public function processStmtNode( $throwPoints = []; $impurePoints = []; - $traitStorage = $storage->duplicate(); - $traitStorage->pendingFibers = []; - $this->processTraitUse($stmt, $scope, $traitStorage, $nodeCallback); - $this->processPendingFibers($traitStorage); + // fresh storage - the same trait node objects are processed once per + // using class and fibers must not see results from a previous pass + $traitStorage = new ExpressionResultStorage(); + $scope->pushExpressionResultStorage($traitStorage); + try { + $this->processTraitUse($stmt, $scope, $traitStorage, $nodeCallback); + $this->processPendingFibers($traitStorage); + } finally { + $scope->popExpressionResultStorage(); + } + + // class-level node callbacks (like ClassMethodsNode) are invoked with + // the outer storage but ask about expressions inside the used trait + $storage->mergeResults($traitStorage); } elseif ($stmt instanceof Foreach_) { if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); @@ -1529,8 +1722,9 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new InForeachNode($stmt), $scope, $storage); $originalScope = $scope; $bodyScope = $scope; - $foreachIterateeType = $originalScope->getType($stmt->expr); - $foreachNativeIterateeType = $originalScope->getNativeType($stmt->expr); + + $foreachIterateeType = $condResult->getType(); + $foreachNativeIterateeType = $condResult->getNativeType(); if ($stmt->keyVar instanceof Variable) { $keyTypeExpr = new NativeTypeExpr( @@ -1555,42 +1749,95 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, $virtualAssign, $scope, $storage); } + // the "iteratee !== []" narrowing every loop pass merges in - composed + // once from the iteratee's result (the same sentinel comparison the + // walked synthetic would delegate to); the walk is the composition's + // miss seam + $nonEmptyIterateeScope = $scope; + if ($this->polluteScopeWithAlwaysIterableForeach) { + $identicalNarrowingHelper = $this->container->getByType(IdenticalNarrowingHelper::class); + $emptyArrayType = new ConstantArrayType([], []); + $nonEmptyTypes = $identicalNarrowingHelper->specifyIdenticalAgainstType( + $stmt->expr, + $condResult, + $arrayComparisonExpr->right, + $emptyArrayType, + TypeSpecifierContext::createFalse(), + $scope, + $identicalNarrowingHelper->captureFirstArgResult($stmt->expr, $storage), + static function () use ($condResult, $emptyArrayType): Type { + $iterateeType = $condResult->getType(); + if ($iterateeType->equals($emptyArrayType)) { + return new ConstantBooleanType(true); + } + if ($emptyArrayType->isSuperTypeOf($iterateeType)->no()) { + return new ConstantBooleanType(false); + } + + return new BooleanType(); + }, + ); + $nonEmptyIterateeScope = $nonEmptyTypes !== null + ? $scope->applySpecifiedTypes($nonEmptyTypes) + : $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()); + } + $originalStorage = $storage; $unrolledEndScope = null; $unrolledTotalKeys = null; - $iterateeScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; if ($context->isTopLevel()) { $storage = $originalStorage->duplicate(); - $originalScope = $iterateeScope; - $foreachIterateeType = $originalScope->getType($stmt->expr); - $foreachNativeIterateeType = $originalScope->getNativeType($stmt->expr); + $originalScope = $nonEmptyIterateeScope; + // $originalScope may narrow the iteratee to a non-empty array - a genuinely + // different scope than its own. The narrowing is tracked by the scope + // (getTypeOnScope's authoritative read), so the iteratee only needs + // reprocessing there when the scope neither owns nor matches its state. + if ($condResult->answersOnScope($originalScope, false) && $condResult->answersOnScope($originalScope, true)) { + $foreachIterateeType = $condResult->getTypeOnScope($originalScope, false); + $foreachNativeIterateeType = $condResult->getTypeOnScope($originalScope, true); + } else { + // the duplicate lets subresults whose state matches answer from + // the already-processed iteratee instead of being re-priced + $iterateeResult = $this->processExprOnDemand($stmt->expr, $originalScope, $originalStorage->duplicate()); + $foreachIterateeType = $iterateeResult->getType(); + $foreachNativeIterateeType = $iterateeResult->getNativeType(); + } $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context, $foreachIterateeType, $foreachNativeIterateeType); if ($unrolledResult !== null) { $bodyScope = $unrolledResult['bodyScope']; $unrolledEndScope = $unrolledResult['endScope']; $unrolledTotalKeys = $unrolledResult['totalKeys']; } else { - $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); + $scope->pushExpressionResultStorage($storage); + try { + $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); + } finally { + $scope->popExpressionResultStorage(); + } $count = 0; $prevEntryScope = null; do { $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($iterateeScope); + $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is - // skipped + // reproduces the previous pass's exit, so the verification walk is skipped $bodyScope = $prevScope; break; } $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + $scope->pushExpressionResultStorage($storage); + try { + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + } finally { + $scope->popExpressionResultStorage(); } if ($bodyScope->equals($prevScope)) { break; @@ -1604,7 +1851,7 @@ public function processStmtNode( } } - $bodyScope = $bodyScope->mergeWith($iterateeScope); + $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); $storage = $originalStorage; $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; @@ -1656,7 +1903,18 @@ public function processStmtNode( $finalScope = $unrolledEndScope; } - $exprType = $scope->getType($stmt->expr); + // $scope is the post-loop scope; the body may have modified the iteratee + // (e.g. $arr[] = ...). A tracked iteratee reads the modified type off the + // scope (getTypeOnScope's authoritative read); only an untracked one whose + // inputs the body changed needs reprocessing there to observe it. + if ($condResult->answersOnScope($scope, false) && $condResult->answersOnScope($scope, true)) { + $exprType = $condResult->getTypeOnScope($scope, false); + $exprNativeType = $condResult->getTypeOnScope($scope, true); + } else { + $postLoopIterateeResult = $this->processExprOnDemand($stmt->expr, $scope, new ExpressionResultStorage()); + $exprType = $postLoopIterateeResult->getType(); + $exprNativeType = $postLoopIterateeResult->getNativeType(); + } $hasExpr = $scope->hasExpressionType($stmt->expr); if ( count($breakExitPoints) === 0 @@ -1674,8 +1932,10 @@ public function processStmtNode( foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { if ($keyVarExpr !== null) { $arrayExprDimFetch = new ArrayDimFetch($stmt->expr, $keyVarExpr); - $dimFetchType = $scopeWithIterableValueType->getType($arrayExprDimFetch); - $dimFetchNativeType = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch); + // enterForeach tracks this exact dim fetch - the tracked-holder + // fast path answers without pricing the synthetic node + $dimFetchType = $this->readScopeStateOrSyntheticType($arrayExprDimFetch, $scopeWithIterableValueType); + $dimFetchNativeType = $this->readScopeStateOrSyntheticType($arrayExprDimFetch, $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()); // Condition-based narrowings like `is_string($type)` apply to the value // variable but not automatically to the array dim fetch, even though the // two describe the same element for a given iteration. If the value var @@ -1684,23 +1944,28 @@ public function processStmtNode( // the loop's final array rewrite below picks up the sharper element type. if ($originalValueExpr !== null && $scopeWithIterableValueType->hasExpressionType($originalValueExpr)->yes()) { // read the loop value variable's narrowed type directly by name - - // it is an assigned (not processExprNode-processed) variable - // ($originalValueExpr !== null implies a string-named Variable) + // it is an assigned (not processExprNode-processed) variable, so + // getVariableType() consumes its tracked type without pricing the + // unprocessed node on demand. ($originalValueExpr !== null implies + // the value var is a string-named Variable.) $valueVarType = $scopeWithIterableValueType->getVariableType($stmt->valueVar->name); if ($dimFetchType->isSuperTypeOf($valueVarType)->yes()) { $dimFetchType = $valueVarType; } - $valueVarNativeType = $scopeWithIterableValueType->getNativeType($stmt->valueVar); + $valueVarNativeType = $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()->getVariableType($stmt->valueVar->name); if ($dimFetchNativeType->isSuperTypeOf($valueVarNativeType)->yes()) { $dimFetchNativeType = $valueVarNativeType; } } - $keyLoopTypes[] = $scopeWithIterableValueType->getType($keyVarExpr); - $keyLoopNativeTypes[] = $scopeWithIterableValueType->getNativeType($keyVarExpr); + $keyLoopTypes[] = $this->readScopeStateOrSyntheticType($keyVarExpr, $scopeWithIterableValueType); + $keyLoopNativeTypes[] = $this->readScopeStateOrSyntheticType($keyVarExpr, $scopeWithIterableValueType); } else { - // No key variable: the narrowed value var is the array element type directly. + // No key variable: the narrowed value var is the array element type + // directly. Read it by name (assigned, not processExprNode-processed); + // no key var implies $originalValueExpr !== null, so the value var is + // a string-named Variable. $dimFetchType = $scopeWithIterableValueType->getVariableType($stmt->valueVar->name); - $dimFetchNativeType = $scopeWithIterableValueType->getNativeType($stmt->valueVar); + $dimFetchNativeType = $scopeWithIterableValueType->doNotTreatPhpDocTypesAsCertain()->getVariableType($stmt->valueVar->name); } $arrayDimFetchLoopTypes[] = $dimFetchType; $arrayDimFetchLoopNativeTypes[] = $dimFetchNativeType; @@ -1712,7 +1977,7 @@ public function processStmtNode( $valueTypeChanged = !$arrayDimFetchLoopType->equals($exprType->getIterableValueType()); $keyTypeChanged = false; $keyLoopType = $exprType->getIterableKeyType(); - $keyLoopNativeType = $scope->getNativeType($stmt->expr)->getIterableKeyType(); + $keyLoopNativeType = $exprNativeType->getIterableKeyType(); if ($keyVarExpr !== null) { $keyLoopType = TypeCombinator::union(...$keyLoopTypes); $keyLoopNativeType = TypeCombinator::union(...$keyLoopNativeTypes); @@ -1728,7 +1993,7 @@ public function processStmtNode( $newExprType = $newExprType->mapKeyType(static fn (Type $type): Type => $keyLoopType); } - $nativeExprType = $scope->getNativeType($stmt->expr); + $nativeExprType = $exprNativeType; $newExprNativeType = $nativeExprType; if ($valueTypeChanged) { $newExprNativeType = $newExprNativeType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopNativeType); @@ -1756,7 +2021,7 @@ public function processStmtNode( $isIterableAtLeastOnce = $exprType->isIterableAtLeastOnce(); if ($isIterableAtLeastOnce->maybe() || $exprType->isIterable()->no()) { - $finalScope = $finalScope->mergeWith($scope->filterByTruthyValue(new BooleanOr( + $finalScope = $finalScope->mergeWith($this->narrowScopeWithCondition($scope, new BooleanOr( new BinaryOp\Identical( $stmt->expr, new Array_([]), @@ -1764,7 +2029,7 @@ public function processStmtNode( new FuncCall(new Name\FullyQualified('is_object'), [ new Arg($stmt->expr), ]), - ))); + ), TypeSpecifierContext::createTruthy())); } elseif ($isIterableAtLeastOnce->no() || $finalScopeResult->isAlwaysTerminating()) { $finalScope = $scope; } elseif (!$this->polluteScopeWithAlwaysIterableForeach) { @@ -1776,7 +2041,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $finalScopeResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $finalScopeResult->getImpurePoints()); } - $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr); + $traversableThrowPoint = $this->getTraversableForeachThrowPoint($scope, $stmt->expr, $exprType); if ($traversableThrowPoint !== null) { $throwPoints[] = $traversableThrowPoint; } @@ -1795,24 +2060,32 @@ public function processStmtNode( } elseif ($stmt instanceof While_) { $originalStorage = $storage; $storage = $originalStorage->duplicate(); - $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); - $condScope = $condResult->getFalseyScope(); - if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { - if (!$this->polluteScopeWithLoopInitialAssignments) { - $scope = $condScope->mergeWith($scope); - } + // pass-local storages are pushed for the duration of each pass so + // in-pass asks (applySpecifiedTypes pricing, branch-scope derivation) + // read the pass's own results instead of re-pricing on demand + $scope->pushExpressionResultStorage($storage); + try { + $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); + $condScope = $condResult->getFalseyScope(); + if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { + if (!$this->polluteScopeWithLoopInitialAssignments) { + $scope = $condScope->mergeWith($scope); + } - return new InternalStatementResult( - $scope, - $condResult->hasYield(), - false, - [], - $condResult->getThrowPoints(), - $condResult->getImpurePoints(), - ); + return new InternalStatementResult( + $scope, + $condResult->hasYield(), + false, + [], + $condResult->getThrowPoints(), + $condResult->getImpurePoints(), + ); + } + $bodyScope = $condResult->getTruthyScope(); + } finally { + $scope->popExpressionResultStorage(); } - $bodyScope = $condResult->getTruthyScope(); if ($context->isTopLevel()) { $count = 0; @@ -1822,18 +2095,22 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($scope); if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is - // skipped + // reproduces the previous pass's exit, so the verification walk is skipped $bodyScope = $prevScope; break; } $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + $scope->pushExpressionResultStorage($storage); + try { + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + } finally { + $scope->popExpressionResultStorage(); } if ($bodyScope->equals($prevScope)) { break; @@ -1849,14 +2126,21 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($scope); $bodyScopeMaybeRan = $bodyScope; $storage = $originalStorage; - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); + $bodyCondResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $bodyScope = $bodyCondResult->getTruthyScope(); $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); - $finalScope = $finalScopeResult->getScope()->filterByFalseyValue($stmt->cond); + $finalScope = $finalScopeResult->getScope(); + // the loop condition narrows the post-loop scope to its falsey branch; + // $finalScope (after the body ran) is a different scope than the condition's + // own, so reprocess the condition there rather than re-running its result. + // The duplicate lets subresults whose state did not change in the body + // answer from the final pass instead of being re-priced. + $finalScope = $finalScope->applySpecifiedTypes($this->processExprOnDemand($stmt->cond, $finalScope, $storage->duplicate())->getSpecifiedTypesForScope($finalScope, TypeSpecifierContext::createFalsey())); $alwaysIterates = false; $neverIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScopeMaybeRan->getType($stmt->cond) : $bodyScopeMaybeRan->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyCondResult->getType() : $bodyCondResult->getNativeType())->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } @@ -1923,24 +2207,29 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($scope); if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is - // skipped (and repeats only idempotent merges into the final scope) + // reproduces the previous pass's exit (and repeats only idempotent + // merges into the final scope), so the verification walk is skipped $bodyScope = $prevScope; break; } $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); - foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { - $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + $scope->pushExpressionResultStorage($storage); + try { + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); + foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + } finally { + $scope->popExpressionResultStorage(); } - $bodyScope = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); if ($bodyScope->equals($prevScope)) { break; } @@ -1961,9 +2250,16 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); } + // the condition is processed once on the post-body scope; its result + // answers both the always-iterates check below and the falsey post-loop + // scope - the previous scope-based read here was a guaranteed storage + // miss (the condition was only ever stored into discarded convergence + // duplicates) that re-priced the condition on demand before this walk + $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScope->getType($stmt->cond) : $bodyScope->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -1979,13 +2275,10 @@ public function processStmtNode( $finalScope = $scope; } if (!$alwaysTerminating) { - $condResult = $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); $hasYield = $condResult->hasYield(); $throwPoints = $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); $finalScope = $condResult->getFalseyScope(); - } else { - $this->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); } $breakExitPoints = $bodyScopeResult->getExitPointsByType(Break_::class); @@ -2025,22 +2318,26 @@ public function processStmtNode( $lastCondExpr = array_last($stmt->cond); if (count($stmt->cond) > 0) { $storage = $originalStorage->duplicate(); + $scope->pushExpressionResultStorage($storage); + try { + foreach ($stmt->cond as $condExpr) { + $condResult = $this->processExprNode($stmt, $condExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); + $initScope = $condResult->getScope(); + + // only the last condition expression is relevant whether the loop continues + // see https://www.php.net/manual/en/control-structures.for.php + if ($condExpr === $lastCondExpr) { + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); + $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); + } - foreach ($stmt->cond as $condExpr) { - $condResult = $this->processExprNode($stmt, $condExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $initScope = $condResult->getScope(); - - // only the last condition expression is relevant whether the loop continues - // see https://www.php.net/manual/en/control-structures.for.php - if ($condExpr === $lastCondExpr) { - $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); - $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); + $hasYield = $hasYield || $condResult->hasYield(); + $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); + $bodyScope = $condResult->getTruthyScope(); } - - $hasYield = $hasYield || $condResult->hasYield(); - $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); - $bodyScope = $condResult->getTruthyScope(); + } finally { + $scope->popExpressionResultStorage(); } } @@ -2053,27 +2350,31 @@ public function processStmtNode( $bodyScope = $bodyScope->mergeWith($initScope); if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is - // skipped + // reproduces the previous pass's exit, so the verification walk is skipped $bodyScope = $prevScope; break; } $prevEntryScope = $bodyScope; - if ($lastCondExpr !== null) { - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - } - $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } + $scope->pushExpressionResultStorage($storage); + try { + if ($lastCondExpr !== null) { + $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + } + $bodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } - foreach ($stmt->loop as $loopExpr) { - $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); - $bodyScope = $exprResult->getScope(); - $hasYield = $hasYield || $exprResult->hasYield(); - $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + foreach ($stmt->loop as $loopExpr) { + $exprResult = $this->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); + $bodyScope = $exprResult->getScope(); + $hasYield = $hasYield || $exprResult->hasYield(); + $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + } + } finally { + $scope->popExpressionResultStorage(); } if ($bodyScope->equals($prevScope)) { @@ -2092,9 +2393,14 @@ public function processStmtNode( $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); if ($lastCondExpr !== null) { - $alwaysIterates = $alwaysIterates->and($bodyScope->getType($lastCondExpr)->toBoolean()->isTrue()); - $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); - $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); + // process the condition once and read the always-iterates check off + // its result - the previous scope-based read was a guaranteed + // storage miss (the condition was only stored into discarded + // convergence duplicates) that re-priced it on demand + $condResult = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $alwaysIterates = $alwaysIterates->and($condResult->getType()->toBoolean()->isTrue()); + $bodyScope = $condResult->getTruthyScope(); + $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope, $storage); } $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints(); @@ -2110,7 +2416,7 @@ public function processStmtNode( $finalScope = $finalScope->generalizeWith($loopScope); if ($lastCondExpr !== null) { - $finalScope = $finalScope->filterByFalseyValue($lastCondExpr); + $finalScope = $this->narrowScopeWithCondition($finalScope, $lastCondExpr, TypeSpecifierContext::createFalsey()); } $breakExitPoints = $finalScopeResult->getExitPointsByType(Break_::class); @@ -2194,7 +2500,23 @@ public function processStmtNode( $caseNode->cond->getStartLine(), $caseKey === $lastNonDefaultCaseKey, ); - $branchScope = $caseResult->getScope()->filterByTruthyValue($condExpr); + // the == narrowing composed from the subject's and the case's + // results (what the walked synthetic delegates to); the walk is + // the composition's miss seam + $caseEqualTypes = $this->container->getByType(IdenticalNarrowingHelper::class)->specifyEqual( + $this, + $stmt->cond, + $caseNode->cond, + $condResult, + $caseResult, + TypeSpecifierContext::createTruthy(), + $caseResult->getScope(), + null, + null, + ); + $branchScope = $caseEqualTypes !== null + ? $caseResult->getScope()->applySpecifiedTypes($caseEqualTypes->setRootExpr($condExpr)) + : $this->narrowScopeWithCondition($caseResult->getScope(), $condExpr, TypeSpecifierContext::createTruthy()); } else { $hasDefaultCase = true; $fullCondExpr = null; @@ -2220,7 +2542,7 @@ public function processStmtNode( $alwaysTerminating = $alwaysTerminating && $branchFinalScopeResult->isAlwaysTerminating(); $prevScope = null; if (isset($fullCondExpr)) { - $scopeForBranches = $scopeForBranches->filterByFalseyValue($fullCondExpr); + $scopeForBranches = $this->narrowScopeWithCondition($scopeForBranches, $fullCondExpr, TypeSpecifierContext::createFalsey()); $fullCondExpr = null; } if (!$branchFinalScopeResult->isAlwaysTerminating()) { @@ -2235,7 +2557,13 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new SwitchConditionNode($stmt->cond, $switchConditionArms, $stmt), $scope, $storage); } - $exhaustive = $scopeForBranches->getType($stmt->cond) instanceof NeverType; + // $scopeForBranches is the subject narrowed by "none of the cases + // matched". The narrowing is tracked by the scope (getTypeOnScope's + // authoritative read); only an untracked subject needs reprocessing there. + $remainingCaseType = $condResult->answersOnScope($scopeForBranches, false) + ? $condResult->getTypeOnScope($scopeForBranches, false) + : $this->processExprOnDemand($stmt->cond, $scopeForBranches, new ExpressionResultStorage())->getType(); + $exhaustive = $remainingCaseType instanceof NeverType; if (!$hasDefaultCase && !$exhaustive) { $alwaysTerminating = false; @@ -2488,7 +2816,7 @@ public function processStmtNode( $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); if ($var instanceof ArrayDimFetch && $var->dim !== null) { - $varType = $scope->getType($var->var); + $varType = $this->readStoredResult($var->var, $storage)->getTypeOnScope($scope, false); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->container->getByType(MethodThrowPointHelper::class)->getThrowPointsForCallOnType( $scope, @@ -2499,9 +2827,8 @@ public function processStmtNode( } // wrap the already-processed chain in ExistingArrayDimFetch nodes - // referencing the original sub-expressions - the unset statement's - // own walk already processed them, and the assign target - // preparation prices the chain without re-walking it + // referencing the original sub-expressions, so the virtual assign + // reads their stored results instead of re-walking a clone $buildExistingChain = static function (Expr $node) use (&$buildExistingChain): Expr { if (!$node instanceof ArrayDimFetch || $node->dim === null) { return $node; @@ -2512,7 +2839,23 @@ public function processStmtNode( $node->dim, ); }; - $scope = $this->processVirtualAssign($scope, $storage, $stmt, $buildExistingChain($var->var), new UnsetOffsetExpr($var->var, $var->dim), $nodeCallback)->getScope(); + $clonedVar = $buildExistingChain($var->var); + $unsetOffsetExpr = new UnsetOffsetExpr($var->var, $var->dim); + $scope = $this->processVirtualAssign( + $scope, + $storage, + $stmt, + $clonedVar, + $unsetOffsetExpr, + $nodeCallback, + // composed from the chain results the unset target's walk just stored + $this->container->getByType(VirtualExprResultHelper::class)->createUnsetOffsetExprResult( + $scope, + $unsetOffsetExpr, + $this->readStoredResult($var->var, $storage), + $this->readStoredResult($var->dim, $storage), + ), + )->getScope(); } elseif ($var instanceof PropertyFetch) { $scope = $scope->invalidateExpression($var); $impurePoints[] = new ImpurePoint( @@ -2849,6 +3192,179 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo return $scope; } + /** + * Processes an expression outside the normal AST traversal - e.g. a synthetic + * node a rule or extension asks about. Real AST nodes contained in it return + * their already-stored results instead of being processed again. New results + * are stored into the given storage - pass a duplicate to keep them isolated. + */ + /** + * Processes an expression whose already-walked subtrees must be CONSUMED + * from their stored results instead of re-walked: the nullsafe handlers + * process the receiver once (real callbacks) and then walk the plain twin, + * whose receiver subtree answers from storage, re-anchored to the twin's + * (ensured) scope. + * + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function processExprNodeConsumingStored(Node\Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + $previous = $this->consumeStoredExpressionResults; + $this->consumeStoredExpressionResults = true; + try { + return $this->processExprNode($stmt, $expr, $scope, $storage, $nodeCallback, $context); + } finally { + $this->consumeStoredExpressionResults = $previous; + } + } + + public function processExprOnDemand(Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage): ExpressionResult + { + // save/restore, never reset: on-demand walks nest (a typeCallback + // evaluated mid-walk prices another synthetic node) and a hard reset + // would turn stored-result consumption off for the rest of the outer + // walk - re-processing every remaining subtree and bypassing the + // closure-argument consume guards in processArgs() + $previous = $this->returnStoredExpressionResults; + $this->returnStoredExpressionResults = true; + $scope->pushExpressionResultStorage($storage); + try { + return $this->processExprNode( + new Node\Stmt\Expression($expr), + $expr, + $scope, + $storage, + new NoopNodeCallback(), + ExpressionContext::createTopLevel(), + ); + } finally { + $scope->popExpressionResultStorage(); + $this->returnStoredExpressionResults = $previous; + } + } + + /** + * The stored ExpressionResult of a node processExprNode() already processed + * into the given storage - the caller asserts the processing order by + * holding the very storage it processed the node into (a scope-based lookup + * would miss loop-convergence storages, which are never scope-visible). + * Throws when the node has no stored result. + */ + public function readStoredResult(Expr $expr, ExpressionResultStorage $storage): ExpressionResult + { + $result = $storage->findExpressionResult($expr); + if ($result === null) { + throw new ShouldNotHappenException(sprintf( + '%s on line %d has no stored ExpressionResult - it was not processed by processExprNode().', + get_class($expr), + $expr->getStartLine(), + )); + } + + return $result; + } + + /** + * The type, on the given scope, of a node that may or may not have a stored + * ExpressionResult. Every call site of this method is UNDECIDED about whether + * the node was already analysed - each should eventually either consume the + * node's ExpressionResult where it was processed or be a synthetic node + * (processSyntheticOnDemand()). + */ + public function readTypeOfMaybeStored(Expr $expr, MutatingScope $scope): Type + { + $storage = $scope->getCurrentExpressionResultStorage(); + $result = $storage !== null ? $storage->findExpressionResult($expr) : null; + if ($result !== null) { + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + return $this->readScopeStateOrSyntheticType($expr, $scope); + } + + /** + * The type the scope itself knows for the expression, without any node + * processing: a string-named variable read is scope state (mirrors + * VariableHandler's typeCallback), and a type tracked for the whole + * expression answers directly - an on-demand walk would return that very + * holder anyway (the fresh result's beforeScope is the asking scope), + * after paying the walk. Null when the scope has no answer; the caller + * decides whether that means a synthetic walk (processSyntheticOnDemand()) + * or an invariant violation. + */ + public function findScopeStateType(Expr $expr, MutatingScope $scope): ?Type + { + if ($expr instanceof Expr\Variable && is_string($expr->name)) { + if ($scope->hasVariableType($expr->name)->no()) { + return new ErrorType(); + } + + return $scope->getVariableType($expr->name); + } + + if ( + !$expr instanceof Expr\Variable + && !$expr instanceof Expr\Closure + && !$expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($expr)->yes() + ) { + return $scope->getTrackedExpressionType($expr); + } + + return null; + } + + /** + * The type, on the given scope, of a node the caller knows has no stored + * ExpressionResult in its walk: scope state (variable read / tracked + * holder) answers without a walk, anything else is priced as a synthetic + * node. + */ + public function readScopeStateOrSyntheticType(Expr $expr, MutatingScope $scope): Type + { + return $this->findScopeStateType($expr, $scope) ?? $this->processSyntheticOnDemand($expr, $scope)->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + /** + * Fires the PHPSTAN_GUARD_NW diagnostic when a real (non-synthetic) AST node + * reaches an on-demand pricing path without having been processed and stored + * by processExprNode() first. Mirrors the guard in MutatingScope::getType(): + * such a node should be answered from its stored ExpressionResult, never + * re-priced as if it were synthetic. Dormant unless PHPSTAN_GUARD_NW=1. + */ + private function guardAgainstUnprocessedRealNode(Expr $expr, string $caller): void + { + if ( + !self::$guardNewWorld + || !isset(self::$guardRealExprIds[spl_object_id($expr)]) + || isset(self::$guardProcessedExprIds[spl_object_id($expr)]) + ) { + return; + } + + throw new ShouldNotHappenException(sprintf( + '%s() asked about non-synthetic %s on line %d before it was processed by processExprNode() - it should consume the node\'s ExpressionResult instead.', + $caller, + get_class($expr), + $expr->getStartLine(), + )); + } + + /** + * Processes a synthetic node (one an ExprHandler built itself) on a duplicate + * of the storage of the analysis currently in progress, mirroring + * MutatingScope::resolveTypeOfNewWorldHandlerNode(): the duplicate isolates + * the synthetic node's own stored result from the live storage while its real + * subnodes still resolve from the fallback. + */ + public function processSyntheticOnDemand(Expr $expr, MutatingScope $scope): ExpressionResult + { + $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); + $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); + + return $this->processExprOnDemand($expr, $scope, $current->duplicate()); + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -2860,6 +3376,66 @@ public function processExprNode( callable $nodeCallback, ExpressionContext $context, ): ExpressionResult + { + if ($this->returnStoredExpressionResults || $this->consumeStoredExpressionResults) { + $storedResult = $storage->findExpressionResult($expr); + // a stored result only answers when the current scope agrees with its + // evaluation position on the variables the expression reads - a + // counterfactual walk (an extension re-binding a variable and pricing + // a real subtree, e.g. array_filter's per-element callback evaluation) + // re-processes the node on its own scope instead. In CONSUME mode the + // divergence is intentional (an ensured-non-null device) and the + // stored result is consumed unconditionally, re-anchored below. + if ($storedResult !== null && ($this->consumeStoredExpressionResults || $storedResult->askScopeVariableStateMatches($scope, $scope->nativeTypesPromoted))) { + // a foreign-position answer must not thread its original walk + // scopes into THIS walk - re-anchor it to the asking position so + // subsequent operands keep evaluating on the asking scope + if ($storedResult->getBeforeScope() === $scope) { + return $storedResult; + } + + $reanchored = $storedResult->atAskPosition($scope); + if ($this->consumeStoredExpressionResults) { + // the re-anchored view IS this walk's result for the node + // (the nullsafe twin's receiver at the ensured position) - + // store it so later asks (rules' fiber reads) see the same + // result the twin walk itself consumed, exactly like the + // receiver walked inside the twin used to be stored + $this->storeExpressionResult($storage, $expr, $reanchored); + } + + return $reanchored; + } + } + + // Track that this expression is being processed. A fiber suspended on it + // (a rule asked its type before processing reached it) must not be + // flushed at a nested statement-list boundary inside this very + // expression - e.g. an immediately-invoked closure's body. It is resumed + // when this processExprNode stores the result below. + $exprId = spl_object_id($expr); + $this->processingExprIds[$exprId] = ($this->processingExprIds[$exprId] ?? 0) + 1; + + try { + return $this->processExprNodeInternal($stmt, $expr, $scope, $storage, $nodeCallback, $context); + } finally { + if (--$this->processingExprIds[$exprId] === 0) { + unset($this->processingExprIds[$exprId]); + } + } + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processExprNodeInternal( + Node\Stmt $stmt, + Expr $expr, + MutatingScope $scope, + ExpressionResultStorage $storage, + callable $nodeCallback, + ExpressionContext $context, + ): ExpressionResult { if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { @@ -2883,6 +3459,10 @@ public function processExprNode( isAlwaysTerminating: $newExprResult->isAlwaysTerminating(), throwPoints: $newExprResult->getThrowPoints(), impurePoints: $newExprResult->getImpurePoints(), + // the first-class callable closure type lives on the *CallableNode + // result; delegate so getType() of the original CallLike answers from it + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $newExprResult->getNativeType() : $newExprResult->getType()), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); $this->storeExpressionResult($storage, $expr, $expressionResult); return $expressionResult; @@ -2895,30 +3475,19 @@ public function processExprNode( $expressionResult = $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); $this->storeExpressionResult($storage, $expr, $expressionResult); // the call is now processed and stored; emit a virtual node so - // impossible-check rules run on the fully processed call instead of - // asking the scope before the call node itself is processed + // impossible-check rules read its specified types from the result + // instead of asking the scope before the call node is processed if ($expr instanceof FuncCall) { - $this->callNodeCallbackWithExpression($nodeCallback, new FunctionCallExpressionNode($expr), $scope, $storage, $context); + $this->callNodeCallbackWithExpression($nodeCallback, new FunctionCallExpressionNode($expr, $expressionResult), $scope, $storage, $context); } elseif ($expr instanceof MethodCall) { - $this->callNodeCallbackWithExpression($nodeCallback, new MethodCallExpressionNode($expr), $scope, $storage, $context); + $this->callNodeCallbackWithExpression($nodeCallback, new MethodCallExpressionNode($expr, $expressionResult), $scope, $storage, $context); } elseif ($expr instanceof StaticCall) { - $this->callNodeCallbackWithExpression($nodeCallback, new StaticMethodCallExpressionNode($expr), $scope, $storage, $context); + $this->callNodeCallbackWithExpression($nodeCallback, new StaticMethodCallExpressionNode($expr, $expressionResult), $scope, $storage, $context); } return $expressionResult; } - $expressionResult = $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $expr, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - $this->storeExpressionResult($storage, $expr, $expressionResult); - - return $expressionResult; + throw new ShouldNotHappenException(sprintf('Unhandled expr: %s', get_class($expr))); } /** @@ -3050,6 +3619,37 @@ public function processClosureNode( ?Type $passedToType, ?Type $nativePassedToType = null, ): ProcessClosureResult + { + // Closures reached as call arguments are processed here directly rather + // than through processExprNode (which tracks the node), so track the + // closure too: the dependency/node callbacks fired for it ask its type + // and suspend a fiber that must not be flushed at a nested boundary + // inside the closure body before the caller stores the closure result. + $exprId = spl_object_id($expr); + $this->processingExprIds[$exprId] = ($this->processingExprIds[$exprId] ?? 0) + 1; + + try { + return $this->processClosureNodeInternal($stmt, $expr, $scope, $storage, $nodeCallback, $context, $passedToType, $nativePassedToType); + } finally { + if (--$this->processingExprIds[$exprId] === 0) { + unset($this->processingExprIds[$exprId]); + } + } + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processClosureNodeInternal( + Node\Stmt $stmt, + Expr\Closure $expr, + MutatingScope $scope, + ExpressionResultStorage $storage, + callable $nodeCallback, + ExpressionContext $context, + ?Type $passedToType, + ?Type $nativePassedToType = null, + ): ProcessClosureResult { foreach ($expr->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -3073,7 +3673,7 @@ public function processClosureNode( $inAssignRightSideVariableName === $use->var->name && $inAssignRightSideExpr !== null ) { - $inAssignRightSideType = $scope->getType($inAssignRightSideExpr); + $inAssignRightSideType = $this->resolveCallableTypeForScope($inAssignRightSideExpr, $scope); if ($inAssignRightSideType instanceof ClosureType) { $variableType = $inAssignRightSideType; } else { @@ -3084,7 +3684,7 @@ public function processClosureNode( $variableType = TypeCombinator::union($scope->getVariableType($inAssignRightSideVariableName), $inAssignRightSideType); } } - $inAssignRightSideNativeType = $scope->getNativeType($inAssignRightSideExpr); + $inAssignRightSideNativeType = $this->resolveCallableTypeForScope($inAssignRightSideExpr, $scope->doNotTreatPhpDocTypesAsCertain()); if ($inAssignRightSideNativeType instanceof ClosureType) { $variableNativeType = $inAssignRightSideNativeType; } else { @@ -3253,11 +3853,14 @@ public function processClosureNode( } /** - * The refined closure type built from the single body walk, swapped onto the - * closure scope so ClosureReturnStatementsNode's rules see the refined - * expected return instead of the shallow entry reflection. + * The closure scope was entered with a shallow reflection (parameters + + * declared return, no body walk - see ClosureTypeResolver::getClosureType() + * with $shallow). Now that the single body walk has gathered the returns, + * build the refined ClosureType from them (no second walk) and swap it onto + * the scope the ClosureReturnStatementsNode fires with, so the return-type + * rules see the refined expected return (e.g. Bar&Foo, not just Foo). * - * @param list $gatheredReturnStatementsWithScope + * @param list $gatheredReturnStatementsWithScope * @param list $gatheredYieldStatementsWithScope * @param list $executionEnds * @param InternalThrowPoint[] $throwPoints @@ -3285,8 +3888,6 @@ private function refineClosureNodeScope( $throwPoints, $impurePoints, $invalidateExpressions, - false, - false, ); return $closureScope->withAnonymousFunctionReflection($refinedClosureType); @@ -3381,12 +3982,9 @@ public function processArrowFunctionNode( // The arrow scope was entered with a shallow reflection (parameters + // declared return, no body walk). Now that the single body walk above has - // run, build the refined arrow function type from the walked body (no - // second walk) and fire InArrowFunctionNode with it, so the node and the - // return-type rules see the refined expected return. The build must not - // write the type cache: its values reflect this call's (possibly - // extension-overridden) parameter typing while its key would match a - // plain pricing ask. + // run, build the refined arrow function type from the body expression's + // stored type (no second walk) and fire InArrowFunctionNode with it, so the + // node and the return-type rules see the refined expected return. $refinedArrowFunctionType = $this->container->getByType(ClosureTypeResolver::class)->buildClosureTypeForArrowFunction( $scope, $expr, @@ -3394,14 +3992,22 @@ public function processArrowFunctionNode( $closureTypeThrowPoints, $closureTypeImpurePoints, $invalidateExpressions, - false, - false, ); $refinedArrowFunctionScope = $arrowFunctionScope->withAnonymousFunctionReflection($refinedArrowFunctionType); $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($refinedArrowFunctionType, $expr), $refinedArrowFunctionScope, $storage); return new ProcessArrowFunctionResult( - $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()), + $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: $exprResult->isAlwaysTerminating(), + throwPoints: $exprResult->getThrowPoints(), + impurePoints: $exprResult->getImpurePoints(), + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ), $arrowFunctionScope, $closureTypeThrowPoints, $closureTypeImpurePoints, @@ -3413,26 +4019,44 @@ public function processArrowFunctionNode( * @param Node\Arg[]|null $args * @return ParameterReflection[]|null */ - public function createCallableParameters(Scope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType): ?array + public function createCallableParameters(MutatingScope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType): ?array { - return $this->doCreateCallableParameters($scope, $closureExpr, $args, $passedToType, static fn (Scope $s, Expr $e) => $s->getType($e)); + return $this->doCreateCallableParameters($scope, $closureExpr, $args, $passedToType, fn (MutatingScope $s, Expr $e): Type => $this->resolveCallableTypeForScope($e, $s)); } /** * @param Node\Arg[]|null $args * @return ParameterReflection[]|null */ - public function createNativeCallableParameters(Scope $scope, Expr $closureExpr, ?array $args, ?Type $nativePassedToType): ?array + public function createNativeCallableParameters(MutatingScope $scope, Expr $closureExpr, ?array $args, ?Type $nativePassedToType): ?array + { + return $this->doCreateCallableParameters($scope, $closureExpr, $args, $nativePassedToType, fn (MutatingScope $s, Expr $e): Type => $this->resolveCallableTypeForScope($e, $s->doNotTreatPhpDocTypesAsCertain())); + } + + /** + * Resolves the type of an expression a callable parameter is derived from - + * either the closure/arrow function whose acceptors describe the parameters, + * or a call argument refining them. A closure/arrow function is resolved + * directly through ClosureTypeResolver (as Scope::getType() would), not by + * processing it on demand: createCallableParameters() runs while that very + * closure is being processed, so on-demand processing would re-enter + * processClosureNodeInternal() endlessly. + */ + private function resolveCallableTypeForScope(Expr $expr, MutatingScope $scope): Type { - return $this->doCreateCallableParameters($scope, $closureExpr, $args, $nativePassedToType, static fn (Scope $s, Expr $e) => $s->getNativeType($e)); + if ($expr instanceof Expr\Closure || $expr instanceof Expr\ArrowFunction) { + return $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $expr); + } + + return $this->readTypeOfMaybeStored($expr, $scope); } /** * @param Node\Arg[]|null $args - * @param Closure(Scope, Expr): Type $typeGetter + * @param Closure(MutatingScope, Expr): Type $typeGetter * @return ParameterReflection[]|null */ - private function doCreateCallableParameters(Scope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType, Closure $typeGetter): ?array + private function doCreateCallableParameters(MutatingScope $scope, Expr $closureExpr, ?array $args, ?Type $passedToType, Closure $typeGetter): ?array { $callableParameters = null; if ($args !== null) { @@ -3785,23 +4409,6 @@ public function processArgs( $gatheredHasName = false; $gatheredArgTypeByIndex = []; - // The intrinsic argument overrides (array_map/filter/walk/find, curl_setopt, - // implode, Closure::bind) rewrite a callback parameter's type from its - // sibling arguments. Apply them up front on the entry scope - the parameter - // pushed on the in-function-call stack while each argument is processed (and - // priced, e.g. a closure's inferred return type) must be the overridden one, - // exactly as when the caller pre-selected via selectFromArgs(). - $parametersAcceptors = ParametersAcceptorSelector::applyIntrinsicArgOverrides( - $args, - $parametersAcceptors, - $namedArgumentsVariants, - $scope, - static fn (Expr $e): Type => $scope->getType($e), - static fn (Expr $e): Type => $scope->getNativeType($e), - static fn (Type $t): Type => $scope->getIterableValueType($t), - static fn (Type $t): Type => $scope->getIterableKeyType($t), - ); - // Metadata acceptor base - NO forward read. The per-argument resolution below picks the // count-correct variant (the by-ref/variadic STRUCTURE is variant-stable except where it is // keyed off the argument count, e.g. sscanf - and the count is known structurally) and @@ -3809,22 +4416,15 @@ public function processArgs( // comes from the post-loop resolved acceptor. $metadataAcceptor = $parametersAcceptors[0] ?? null; - // Both predicates are hoisted out of the per-argument loop - they traverse - // the acceptor's parameter/return types. - $hasTemplateParameterType = $metadataAcceptor !== null - && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor); - $argMetadataIsTypeDriven = count($parametersAcceptors) > 1 || $hasTemplateParameterType; - // Whether selecting an acceptor is type-driven at all: multiple variants to // choose between, templates or conditionals to resolve from the arg types, // or named-argument variants. When it is not, the gathered arg types can // never influence the selected acceptor, so the faithful-return gather walk // of a closure/arrow argument (gatherClosureArgType()) would be pure waste - - // a plain mixed keeps the count/name bookkeeping correct. + // its signature-only shallow type keeps the count/name bookkeeping correct. $typeDrivenAcceptorSelection = count($parametersAcceptors) > 1 || $namedArgumentsVariants !== null - || $hasTemplateParameterType - || ($metadataAcceptor !== null && $metadataAcceptor->getReturnType()->hasTemplateOrLateResolvableType()); + || ($metadataAcceptor !== null && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableType($metadataAcceptor)); $hasYield = false; $throwPoints = []; @@ -3864,6 +4464,7 @@ public function processArgs( return $aOriginal->getStartTokenPos() <=> $bOriginal->getStartTokenPos(); }); + $argResults = []; $countStableMetadataAcceptor = null; foreach ($processingOrder as $i) { $arg = $args[$i]; @@ -3878,12 +4479,15 @@ public function processArgs( $originalArgForGather = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; $gatheredArgTypeByIndex[$i] = $typeDrivenAcceptorSelection ? $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope) - : new MixedType(); + : $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $arg->value, true); $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $gatheredArgTypeByIndex[$i]); } $argMetadataAcceptor = $metadataAcceptor; - if ($metadataAcceptor !== null && $argMetadataIsTypeDriven) { + if ( + $metadataAcceptor !== null + && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) + ) { if ($this->argConsumesResolvedParameterType($arg->value)) { // Resolve the acceptor for this argument from the args gathered SO FAR, padded to the // full argument count with mixed. Closures sort last and by-ref out-params follow the @@ -3991,149 +4595,260 @@ public function processArgs( if ($arg->value instanceof Expr\Closure) { - $restoreThisScope = null; - if ( - $closureBindScopeFactory === null - && $parameter instanceof ExtendedParameterReflection - && !$arg->value->static - ) { - $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); - if ($closureThisType !== null) { - $restoreThisScope = $scopeToPass; - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) - ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); + $storedClosureArgResult = null; + if ($this->returnStoredExpressionResults || $this->consumeStoredExpressionResults) { + // an on-demand re-walk of the enclosing call must not re-run the + // closure's whole by-ref convergence: consume the main walk's + // stored result, or (when the body release already dropped it) + // price the closure through getClosureType's per-node cache - + // a single body walk on miss, none on repeat asks + $storedClosureArgResult = $storage->findExpressionResult($arg->value); + if ($storedClosureArgResult === null) { + $closureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $storedClosureArgResult = $this->expressionResultFactory->create( + $scopeToPass, + beforeScope: $scopeToPass, + expr: $arg->value, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + type: $closureTypeResolver->getClosureType($scopeToPass, $arg->value), + nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + $this->storeExpressionResult($storage, $arg->value, $storedClosureArgResult); } } + if ($storedClosureArgResult !== null) { + $argResults[spl_object_id($arg->value)] = $storedClosureArgResult; + } else { + $restoreThisScope = null; + if ( + $closureBindScopeFactory === null + && $parameter instanceof ExtendedParameterReflection + && !$arg->value->static + ) { + $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); + if ($closureThisType !== null) { + $restoreThisScope = $scopeToPass; + $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) + ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); + } + } - if ($parameter !== null) { - $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + if ($parameter !== null) { + $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); - if ($overwritingParameterType !== null) { - $parameterType = $overwritingParameterType; + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; - // resolve the native flavour through the same extension on the - // natively-promoted scope, so the closure parameters keep - // their native precision too - $overwritingParameterNativeType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass->doNotTreatPhpDocTypesAsCertain()); - if ($overwritingParameterNativeType !== null) { - $parameterNativeType = $overwritingParameterNativeType; + // resolve the native flavour through the same extension on the + // natively-promoted scope, so the closure parameters keep + // their native precision too + $overwritingParameterNativeType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass->doNotTreatPhpDocTypesAsCertain()); + if ($overwritingParameterNativeType !== null) { + $parameterNativeType = $overwritingParameterNativeType; + } } } - } - - $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); - $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType, $parameterNativeType); - // the preferred ClosureType read below now answers from this seed - // instead of walking the body again (unless a parked fiber may - // still complete the gathered data - then it keeps re-walking) - $this->container->getByType(ClosureTypeResolver::class)->seedCacheFromClosureWalk($scopeToPass, $arg->value, $closureResult, $storage); - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { - $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); - $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); - } - - $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( - $closureResult->getScope(), - $scopeToPass, - $arg->value, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - )); - $uses = []; - foreach ($arg->value->uses as $use) { - if (!is_string($use->var->name)) { - continue; + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); + $closureResult = $this->processClosureNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context, $parameterType, $parameterNativeType); + if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $closureResult->getThrowPoints())); + $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); } - $uses[] = $use->var->name; - } - - $scope = $closureResult->getScope(); - $deferredByRefClosureResults[] = $closureResult; - // Prefer the invalidate expressions collected on the ClosureType: those - // are gathered with the closure's pending fibers flushed, so they also - // cover writes that go through a parked fiber (e.g. $this->prop[] = ...), - // unlike $closureResult->getInvalidateExpressions(). - $closureExprType = $scope->getType($arg->value); - $invalidateExpressions = $closureExprType instanceof ClosureType - ? $closureExprType->getInvalidateExpressions() - : $closureResult->getInvalidateExpressions(); - if ($restoreThisScope !== null) { - $nodeFinder = new NodeFinder(); - $cb = static fn ($expr) => $expr instanceof Variable && $expr->name === 'this'; - foreach ($invalidateExpressions as $j => $invalidateExprNode) { - $foundThis = $nodeFinder->findFirst([$invalidateExprNode->getExpr()], $cb); - if ($foundThis === null) { + $closureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( + $closureResult->getScope(), + $scopeToPass, + $arg->value, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + type: $closureTypeResolver->buildClosureTypeForClosure( + $scopeToPass, + $arg->value, + $closureResult->getGatheredReturnStatements(), + $closureResult->getGatheredYieldStatements(), + $closureResult->getExecutionEnds(), + $closureResult->getThrowPoints(), + $closureResult->getClosureTypeImpurePoints(), + $closureResult->getInvalidateExpressions(), + ), + // the native flavour reads the stored native types off the same + // single body walk - no second walk on the promoted scope + nativeType: $closureTypeResolver->buildClosureTypeForClosure( + $scopeToPass, + $arg->value, + $closureResult->getGatheredReturnStatements(), + $closureResult->getGatheredYieldStatements(), + $closureResult->getExecutionEnds(), + $closureResult->getThrowPoints(), + $closureResult->getClosureTypeImpurePoints(), + $closureResult->getInvalidateExpressions(), + true, + ), + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + )); + + $uses = []; + foreach ($arg->value->uses as $use) { + if (!is_string($use->var->name)) { continue; } - unset($invalidateExpressions[$j]); + $uses[] = $use->var->name; } - $invalidateExpressions = array_values($invalidateExpressions); - $scope = $scope->restoreThis($restoreThisScope); - } - if ($this->shouldInvalidateCallbackExpressions($parameter)) { - $deferredInvalidateExpressions[] = [$invalidateExpressions, $uses]; + $scope = $closureResult->getScope(); + $deferredByRefClosureResults[] = $closureResult; + // Prefer the invalidate expressions collected on the ClosureType: those + // are gathered with the closure's pending fibers flushed, so they also + // cover writes that go through a parked fiber (e.g. $this->prop[] = ...), + // unlike $closureResult->getInvalidateExpressions(). + $closureExprType = $scope->getType($arg->value); + $invalidateExpressions = $closureExprType instanceof ClosureType + ? $closureExprType->getInvalidateExpressions() + : $closureResult->getInvalidateExpressions(); + if ($restoreThisScope !== null) { + $nodeFinder = new NodeFinder(); + $cb = static fn ($expr) => $expr instanceof Variable && $expr->name === 'this'; + foreach ($invalidateExpressions as $j => $invalidateExprNode) { + $foundThis = $nodeFinder->findFirst([$invalidateExprNode->getExpr()], $cb); + if ($foundThis === null) { + continue; + } + + unset($invalidateExpressions[$j]); + } + $invalidateExpressions = array_values($invalidateExpressions); + $scope = $scope->restoreThis($restoreThisScope); + } + + if ($this->shouldInvalidateCallbackExpressions($parameter)) { + $deferredInvalidateExpressions[] = [$invalidateExpressions, $uses]; + } } } elseif ($arg->value instanceof Expr\ArrowFunction) { - if ( - $closureBindScopeFactory === null - && $parameter instanceof ExtendedParameterReflection - && !$arg->value->static - ) { - $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); - if ($closureThisType !== null) { - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) - ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); + $storedClosureArgResult = null; + if ($this->returnStoredExpressionResults || $this->consumeStoredExpressionResults) { + // see the Closure branch above - consume or price via the cache + $storedClosureArgResult = $storage->findExpressionResult($arg->value); + if ($storedClosureArgResult === null) { + $closureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $storedClosureArgResult = $this->expressionResultFactory->create( + $scopeToPass, + beforeScope: $scopeToPass, + expr: $arg->value, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + type: $closureTypeResolver->getClosureType($scopeToPass, $arg->value), + nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + $this->storeExpressionResult($storage, $arg->value, $storedClosureArgResult); } } + if ($storedClosureArgResult !== null) { + $argResults[spl_object_id($arg->value)] = $storedClosureArgResult; + } else { + if ( + $closureBindScopeFactory === null + && $parameter instanceof ExtendedParameterReflection + && !$arg->value->static + ) { + $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); + if ($closureThisType !== null) { + $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) + ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); + } + } - if ($parameter !== null) { - $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); + if ($parameter !== null) { + $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); - if ($overwritingParameterType !== null) { - $parameterType = $overwritingParameterType; + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; - // resolve the native flavour through the same extension on the - // natively-promoted scope, so the closure parameters keep - // their native precision too - $overwritingParameterNativeType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass->doNotTreatPhpDocTypesAsCertain()); - if ($overwritingParameterNativeType !== null) { - $parameterNativeType = $overwritingParameterNativeType; + // resolve the native flavour through the same extension on the + // natively-promoted scope, so the closure parameters keep + // their native precision too + $overwritingParameterNativeType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass->doNotTreatPhpDocTypesAsCertain()); + if ($overwritingParameterNativeType !== null) { + $parameterNativeType = $overwritingParameterNativeType; + } } } - } - $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); - $processArrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType, $parameterNativeType); - // the invalidation read below now answers from this seed instead - // of walking the body again (unless a parked fiber may still - // complete the gathered data - then it keeps re-walking) - $this->container->getByType(ClosureTypeResolver::class)->seedCacheFromArrowFunctionWalk($scopeToPass, $arg->value, $processArrowFunctionResult, $storage); - $arrowFunctionResult = $processArrowFunctionResult->getExpressionResult(); - if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { - $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionResult->getThrowPoints())); - $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); - } - if ($this->shouldInvalidateCallbackExpressions($parameter)) { - $arrowFunctionType = $scope->getType($arg->value); - if ($arrowFunctionType instanceof ClosureType) { + $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); + $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType, $parameterNativeType); + $arrowFunctionExprResult = $arrowFunctionResult->getExpressionResult(); + if ($this->callCallbackImmediately($parameter, $parameterType, $calleeReflection)) { + $throwPoints = array_merge($throwPoints, array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->isExplicit() ? InternalThrowPoint::createExplicit($scope, $throwPoint->getType(), $arg->value, $throwPoint->canContainAnyThrowable()) : InternalThrowPoint::createImplicit($scope, $arg->value), $arrowFunctionExprResult->getThrowPoints())); + $impurePoints = array_merge($impurePoints, $arrowFunctionExprResult->getImpurePoints()); + } + $arrowFunctionClosureTypeResolver = $this->container->getByType(ClosureTypeResolver::class); + $arrowFunctionScope = $arrowFunctionResult->getArrowFunctionScope(); + // both flavours are built from the single body walk (see + // ArrowFunctionHandler); the built type also answers the + // invalidate-expressions read below without re-walking the + // still-unstored node through Scope::getType() + $arrowFunctionType = $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( + $scopeToPass, + $arg->value, + $arrowFunctionScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ); + $storedArrowResult = $this->expressionResultFactory->create( + $arrowFunctionExprResult->getScope(), + beforeScope: $scopeToPass, + expr: $arg->value, + hasYield: $arrowFunctionExprResult->hasYield(), + isAlwaysTerminating: $arrowFunctionExprResult->isAlwaysTerminating(), + throwPoints: $arrowFunctionExprResult->getThrowPoints(), + impurePoints: $arrowFunctionExprResult->getImpurePoints(), + type: $arrowFunctionType, + nativeType: $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( + $scopeToPass, + $arg->value, + $arrowFunctionScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + true, + ), + typeCallback: null, + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ); + $this->storeExpressionResult($storage, $arg->value, $storedArrowResult); + // the arg result must be the properly-typed stored result, not + // the body walk's placeholder (whose typeCallback answers mixed) - + // ArgsResult readers price array_push() & co. from it + $argResults[spl_object_id($arg->value)] = $storedArrowResult; + if ($this->shouldInvalidateCallbackExpressions($parameter)) { $deferredInvalidateExpressions[] = [$arrowFunctionType->getInvalidateExpressions(), $arrowFunctionType->getUsedVariables()]; } } - $this->storeExpressionResult($storage, $arg->value, $arrowFunctionResult); } else { - $exprType = $scope->getType($arg->value); $enterExpressionAssignForByRef = $assignByReference && $arg->value instanceof ArrayDimFetch && $arg->value->dim === null; if ($enterExpressionAssignForByRef) { $scopeToPass = $scopeToPass->enterExpressionAssign($arg->value); } $exprResult = $this->processExprNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $context->enterDeep()); + $argResults[spl_object_id($arg->value)] = $exprResult; + $exprType = $exprResult->getType(); $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); @@ -4160,7 +4875,7 @@ public function processArgs( } } - $gatheredArgTypeByIndex[$i] = $exprType; + $gatheredArgTypeByIndex[$i] = $exprResult->getType(); $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArg, $i, $gatheredArgTypeByIndex[$i]); } @@ -4190,14 +4905,10 @@ public function processArgs( // Type-driven resolved acceptor: the arg types gathered on the evolving // scope select (and generic-resolve) the acceptor that drives the call's // return type. Intrinsic overrides are applied on the final scope, - // mirroring the original selectFromArgs(). When the selection is not - // type-driven, the single (already-overridden) acceptor IS the resolved - // acceptor - the fast path selectFromArgs() used to take. + // mirroring the original selectFromArgs(). $resolvedAcceptor = null; if ($parametersAcceptors !== []) { - $resolvedAcceptor = $typeDrivenAcceptorSelection - ? $this->selectArgsMetadataAcceptor($args, $gatheredTypes, $parametersAcceptors, $namedArgumentsVariants, $gatheredHasName, $gatheredUnpack, $scope) - : $metadataAcceptor; + $resolvedAcceptor = $this->selectArgsMetadataAcceptor($args, $gatheredTypes, $parametersAcceptors, $namedArgumentsVariants, $gatheredHasName, $gatheredUnpack, $scope); } // The by-ref OUT writeback reads the metadata acceptor: it is selected from @@ -4206,7 +4917,10 @@ public function processArgs( // now-complete gathered arg types - the post-loop $resolvedAcceptor is exactly // that (same variant, resolved); otherwise the metadata acceptor is already resolved. $writebackAcceptor = $metadataAcceptor; - if ($metadataAcceptor !== null && $argMetadataIsTypeDriven) { + if ( + $metadataAcceptor !== null + && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) + ) { $writebackAcceptor = $resolvedAcceptor; } $writebackParameters = $writebackAcceptor !== null ? $writebackAcceptor->getParameters() : null; @@ -4264,7 +4978,7 @@ public function processArgs( $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { - $argType = $scope->getType($arg->value); + $argType = ($argResults[spl_object_id($arg->value)] ?? $this->readStoredResult($arg->value, $storage))->getTypeOnScope($scope, false); if (!$argType->isObject()->no()) { $nakedReturnType = null; if ($nakedMethodReflection !== null) { @@ -4295,51 +5009,22 @@ public function processArgs( // not storing this, it's scope after processing all args return new ArgsResult( - $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints), + $this->expressionResultFactory->create( + $scope, + $scope, + $callLike, + $hasYield, + $isAlwaysTerminating, + $throwPoints, + $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ), $resolvedAcceptor, + $argResults, ); } - /** - * Applies the intrinsic argument overrides (array_map/filter/walk/find, - * curl_setopt, implode, Closure::bind) on the arg-to-arg evolved scope, - * then type-selects the metadata acceptor over - * the arg types gathered so far. The overrides read sibling arg types - which - * closures-last ordering keeps in scope/$gatheredTypes before any closure. - * - * @param Node\Arg[] $args - * @param array $gatheredTypes - * @param ParametersAcceptor[] $parametersAcceptors - * @param ParametersAcceptor[]|null $namedArgumentsVariants - */ - private function selectArgsMetadataAcceptor(array $args, array $gatheredTypes, array $parametersAcceptors, ?array $namedArgumentsVariants, bool $hasName, bool $unpack, MutatingScope $scope): ParametersAcceptor - { - $overridden = ParametersAcceptorSelector::applyIntrinsicArgOverrides( - $args, - $parametersAcceptors, - $namedArgumentsVariants, - $scope, - static fn (Expr $e): Type => $scope->getType($e), - static fn (Expr $e): Type => $scope->getNativeType($e), - static fn (Type $t): Type => $scope->getIterableValueType($t), - static fn (Type $t): Type => $scope->getIterableKeyType($t), - ); - - return $this->selectArgsAcceptor($gatheredTypes, $overridden, $namedArgumentsVariants, $hasName, $unpack); - } - - /** - * @param array $types - * @param ParametersAcceptor[] $parametersAcceptors - * @param ParametersAcceptor[]|null $namedArgumentsVariants - */ - private function selectArgsAcceptor(array $types, array $parametersAcceptors, ?array $namedArgumentsVariants, bool $hasName, bool $unpack): ParametersAcceptor - { - return $hasName && $namedArgumentsVariants !== null - ? ParametersAcceptorSelector::selectFromTypes($types, $namedArgumentsVariants, $unpack) - : ParametersAcceptorSelector::selectFromTypes($types, $parametersAcceptors, $unpack); - } - /** * Ports the gather-keying of ParametersAcceptorSelector::selectFromArgs(): * indexes the gathered arg type by name (sets $hasName) vs position, and @@ -4385,6 +5070,35 @@ private function addGatheredArgType(array &$types, bool &$unpack, bool &$hasName } } + /** + * Whether processing this argument consumes the generic-RESOLVED parameter + * type: a closure/arrow function does - its parameters and body scope are + * typed from the resolved callable(T) - whether it IS the argument or is + * nested anywhere inside it (the enclosing parameter is pushed on the + * in-function-call stack and the nested closure types itself from there). + * Every other argument only reads variant-stable facts off its parameter. + */ + private function argConsumesResolvedParameterType(Expr $value): bool + { + if ($value instanceof Expr\Closure || $value instanceof Expr\ArrowFunction) { + return true; + } + + // cached on the node - args are re-processed across convergence passes + $cached = $value->getAttribute('phpstanArgContainsClosure'); + if ($cached !== null) { + return $cached; + } + + $contains = (new NodeFinder())->findFirst( + [$value], + static fn (Node $node): bool => $node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction, + ) !== null; + $value->setAttribute('phpstanArgContainsClosure', $contains); + + return $contains; + } + /** * Resolves the type of a closure/arrow function argument for the generic * gather, mirroring ParametersAcceptorSelector::selectFromArgs(): the closure @@ -4412,36 +5126,82 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $ $scope = $scope->pushInFunctionCall(null, $rawParameter, false); } - return $scope->getType($closureExpr); + return $this->resolveCallableTypeForScope($closureExpr, $scope); } /** - * Whether processing this argument consumes the generic-RESOLVED parameter - * type: a closure/arrow function does - its parameters and body scope are - * typed from the resolved callable(T) - whether it IS the argument or is - * nested anywhere inside it (the enclosing parameter is pushed on the - * in-function-call stack and the nested closure types itself from there). - * Every other argument only reads variant-stable facts off its parameter. + * @param array $types + * @param ParametersAcceptor[] $parametersAcceptors + * @param ParametersAcceptor[]|null $namedArgumentsVariants */ - private function argConsumesResolvedParameterType(Expr $value): bool + private function selectArgsAcceptor(array $types, array $parametersAcceptors, ?array $namedArgumentsVariants, bool $hasName, bool $unpack): ParametersAcceptor { - if ($value instanceof Expr\Closure || $value instanceof Expr\ArrowFunction) { - return true; + return $hasName && $namedArgumentsVariants !== null + ? ParametersAcceptorSelector::selectFromTypes($types, $namedArgumentsVariants, $unpack) + : ParametersAcceptorSelector::selectFromTypes($types, $parametersAcceptors, $unpack); + } + + /** + * Applies the intrinsic argument overrides (array_map/filter/walk/find, + * curl_setopt, implode, Closure::bind) on the arg-to-arg evolved scope via + * the non-reprocessing readers, then type-selects the metadata acceptor over + * the arg types gathered so far. The overrides read sibling arg types - which + * closures-last ordering keeps in scope/$gatheredTypes before any closure. + * + * @param Node\Arg[] $args + * @param array $gatheredTypes + * @param ParametersAcceptor[] $parametersAcceptors + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + private function selectArgsMetadataAcceptor(array $args, array $gatheredTypes, array $parametersAcceptors, ?array $namedArgumentsVariants, bool $hasName, bool $unpack, MutatingScope $scope): ParametersAcceptor + { + $overridden = ParametersAcceptorSelector::applyIntrinsicArgOverrides( + $args, + $parametersAcceptors, + $namedArgumentsVariants, + $scope, + fn (Expr $e): Type => $this->readTypeOfMaybeStored($e, $scope), + fn (Expr $e): Type => $this->readTypeOfMaybeStored($e, $scope->doNotTreatPhpDocTypesAsCertain()), + static fn (Type $t): Type => $scope->getIterableValueType($t), + static fn (Type $t): Type => $scope->getIterableKeyType($t), + ); + + return $this->selectArgsAcceptor($gatheredTypes, $overridden, $namedArgumentsVariants, $hasName, $unpack); + } + + /** + * Arguments normalization (reordering, default-filling) can drop an original + * argument from the call processArgs() iterates - duplicate, unknown-named or + * extra arguments in an invalid call. The parameters check still asks their + * types to report the error, so process them too (their result is stored). + * A NoopNodeCallback keeps the dropped arguments out of rule processing, + * matching the behaviour when this guard is off. + */ + public function processDroppedArgs( + Node\Stmt $stmt, + CallLike $originalCall, + CallLike $normalizedCall, + MutatingScope $scope, + ExpressionResultStorage $storage, + ExpressionContext $context, + ): void + { + if ($originalCall === $normalizedCall) { + return; } - // cached on the node - args are re-processed across convergence passes - $cached = $value->getAttribute('phpstanArgContainsClosure'); - if ($cached !== null) { - return $cached; + $keptValueIds = []; + foreach ($normalizedCall->getArgs() as $normalizedArg) { + $keptValueIds[spl_object_id($normalizedArg->value)] = true; } - $contains = (new NodeFinder())->findFirst( - [$value], - static fn (Node $node): bool => $node instanceof Expr\Closure || $node instanceof Expr\ArrowFunction, - ) !== null; - $value->setAttribute('phpstanArgContainsClosure', $contains); + foreach ($originalCall->getArgs() as $originalArg) { + if (isset($keptValueIds[spl_object_id($originalArg->value)])) { + continue; + } - return $contains; + $this->processExprNode($stmt, $originalArg->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + } } /** @@ -4582,8 +5342,21 @@ private function getParameterOutExtensionsType(CallLike $callLike, $calleeReflec /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ - public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback): ExpressionResult + public function processVirtualAssign(MutatingScope $scope, ExpressionResultStorage $storage, Node\Stmt $stmt, Expr $var, Expr $assignedExpr, callable $nodeCallback, ?ExpressionResult $assignedExprResult = null): ExpressionResult { + // work off an available result for the assigned expr: passed by the + // caller, or fabricated from a type-carrying virtual node - threaded + // straight into applyWrite() so its reads compose instead of falling + // back to on-demand pricing of the type, the truthy/falsey narrowing, + // and the synthetic sentinel comparisons + if ( + $assignedExprResult === null + && ($assignedExpr instanceof TypeExpr || $assignedExpr instanceof NativeTypeExpr) + && $storage->findExpressionResult($assignedExpr) === null + ) { + $assignedExprResult = $this->container->getByType(VirtualExprResultHelper::class)->createTypeExprResult($scope, $assignedExpr); + } + $assignHandler = $this->container->getByType(AssignHandler::class); $virtualAssignNodeCallback = VirtualAssignNodeCallback::create($nodeCallback); $target = $assignHandler->prepareTarget( @@ -4601,7 +5374,18 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora return $assignHandler->applyWrite( $this, $target, - $this->expressionResultFactory->create($target->getScope(), beforeScope: $target->getScope(), expr: $assignedExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + $this->expressionResultFactory->create( + $target->getScope(), + beforeScope: $target->getScope(), + expr: $assignedExpr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ), + $assignedExprResult, $stmt, $storage, $virtualAssignNodeCallback, @@ -4669,17 +5453,19 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $variableNode), $scope, $storage); } + $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); $scope = $scope->assignVariable( $name, $varTag->getType(), - $scope->getNativeType($variableNode), + // a plain variable read is scope state + $nativeScope->hasVariableType($name)->no() ? new ErrorType() : $nativeScope->getVariableType($name), $certainty, ); } } if (count($variableLessTags) === 1 && $defaultExpr !== null) { - $originalType = $scope->getType($defaultExpr); + $originalType = $this->readTypeOfMaybeStored($defaultExpr, $scope); $varTag = $variableLessTags[0]; if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); @@ -4944,9 +5730,8 @@ private function tryProcessUnrolledConstantArrayForeach( return ['bodyScope' => $bodyScope, 'endScope' => $endScope, 'totalKeys' => $totalKeys]; } - private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee): ?InternalThrowPoint + private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $iteratee, Type $exprType): ?InternalThrowPoint { - $exprType = $scope->getType($iteratee); $traversableType = new ObjectType(Traversable::class); if ($traversableType->isSuperTypeOf($exprType)->no()) { @@ -5092,8 +5877,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $arrayArg = $args[0]->value; $scope = $scope->assignExpression( new ArrayDimFetch($arrayArg, $stmt->valueVar), - $scope->getType($arrayArg)->getIterableValueType(), - $scope->getNativeType($arrayArg)->getIterableValueType(), + $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($scope, false)->getIterableValueType(), + $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($scope, true)->getIterableValueType(), ); } } @@ -5391,7 +6176,7 @@ public function processCalledMethod(MethodReflection $methodReflection): ?Mutati $statementResult = $executionEnd->getStatementResult(); $endNode = $executionEnd->getNode(); if ($endNode instanceof Node\Stmt\Expression) { - $exprType = $statementResult->getScope()->getType($endNode->expr); + $exprType = $this->readTypeOfMaybeStored($endNode->expr, $statementResult->getScope()->toMutatingScope()); if ($exprType instanceof NeverType && $exprType->isExplicit()) { continue; } @@ -5747,7 +6532,7 @@ private function getNextUnreachableStatements(array $nodes, bool $earlyBinding): return $stmts; } - private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope): MutatingScope + private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, MutatingScope $bodyScope, ExpressionResultStorage $storage): MutatingScope { // infer $items[$i] type from for ($i = 0; $i < count($items); $i++) {...} @@ -5778,12 +6563,12 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->left->name ) { $arrayArg = $lastCondExpr->right->getArgs()[0]->value; - $arrayType = $bodyScope->getType($arrayArg); + $arrayType = $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($bodyScope, false); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->right->getArgs()[0]->value, $lastCondExpr->left), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($bodyScope, true)->getIterableValueType(), ); } } @@ -5803,12 +6588,12 @@ private function inferForLoopExpressions(For_ $stmt, Expr $lastCondExpr, Mutatin && $stmt->init[0]->var->name === $lastCondExpr->right->name ) { $arrayArg = $lastCondExpr->left->getArgs()[0]->value; - $arrayType = $bodyScope->getType($arrayArg); + $arrayType = $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($bodyScope, false); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->left->getArgs()[0]->value, $lastCondExpr->right), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $this->readStoredResult($arrayArg, $storage)->getTypeOnScope($bodyScope, true)->getIterableValueType(), ); } } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index d2da6b934b5..d5178cd71a2 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -4,7 +4,6 @@ use PhpParser\Node; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\ConstFetch; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Expr\MethodCall; @@ -13,42 +12,17 @@ use PhpParser\Node\Name; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Container; -use PHPStan\Node\Expr\AlwaysRememberedExpr; -use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\Printer\ExprPrinter; -use PHPStan\Reflection\Assertions; -use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ReflectionProvider; -use PHPStan\Reflection\ResolvedFunctionVariant; -use PHPStan\ShouldNotHappenException; -use PHPStan\TrinaryLogic; -use PHPStan\Type\Accessory\HasOffsetValueType; -use PHPStan\Type\Accessory\NonEmptyArrayType; -use PHPStan\Type\ConditionalTypeForParameter; -use PHPStan\Type\Constant\ConstantBooleanType; -use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\ExtensionClassHelper; use PHPStan\Type\FunctionTypeSpecifyingExtension; -use PHPStan\Type\Generic\TemplateType; -use PHPStan\Type\IntegerRangeType; use PHPStan\Type\MethodTypeSpecifyingExtension; -use PHPStan\Type\MixedType; -use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\StaticMethodTypeSpecifyingExtension; use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use PHPStan\Type\TypeTraverser; -use function array_key_exists; -use function array_last; -use function array_map; use function array_merge; -use function count; -use function in_array; -use function strtolower; -use function substr; -use const COUNT_NORMAL; #[AutowiredService(name: 'typeSpecifier', factory: '@typeSpecifierFactory::create')] final class TypeSpecifier @@ -92,148 +66,12 @@ public function specifyTypesInCondition( $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); if ($exprHandler !== null) { - return $exprHandler->specifyTypes($this, $scope, $expr, $context); - } - - return $this->specifyDefaultTypes($scope, $expr, $context); - } - - /** @internal */ - public function isNormalCountCall(FuncCall $countFuncCall, Type $typeToCount, Scope $scope): TrinaryLogic - { - if (count($countFuncCall->getArgs()) === 1) { - return TrinaryLogic::createYes(); - } - - $mode = $scope->getType($countFuncCall->getArgs()[1]->value); - return (new ConstantIntegerType(COUNT_NORMAL))->isSuperTypeOf($mode)->result->or($typeToCount->getIterableValueType()->isArray()->negate()); - } - - /** @internal */ - public function specifyTypesForCountFuncCall( - FuncCall $countFuncCall, - Type $type, - Type $sizeType, - TypeSpecifierContext $context, - Scope $scope, - Expr $rootExpr, - ): ?SpecifiedTypes - { - $isConstantArray = $type->isConstantArray(); - $isList = $type->isList(); - $oneOrMore = IntegerRangeType::fromInterval(1, null); - if ( - !$this->isNormalCountCall($countFuncCall, $type, $scope)->yes() - || (!$isConstantArray->yes() && !$isList->yes()) - || !$oneOrMore->isSuperTypeOf($sizeType)->yes() - || $sizeType->isSuperTypeOf($type->getArraySize())->yes() - ) { - return null; - } - - if ($context->falsey() && $isConstantArray->yes()) { - $remainingSize = TypeCombinator::remove($type->getArraySize(), $sizeType); - if (!$remainingSize instanceof NeverType) { - $negatedContext = $context->false() - ? TypeSpecifierContext::createTrue() - : TypeSpecifierContext::createTruthy(); - $result = $this->specifyTypesForCountFuncCall( - $countFuncCall, - $type, - $remainingSize, - $negatedContext, - $scope, - $rootExpr, - ); - if ($result !== null) { - return $result; - } - } - - // Fallback: directly filter constant arrays by their exact sizes. - // This avoids using TypeCombinator::remove() with falsey context, - // which can incorrectly remove arrays whose count doesn't match - // but whose shape is a subtype of the matched array. - $keptTypes = []; - foreach ($type->getConstantArrays() as $arrayType) { - if ($sizeType->isSuperTypeOf($arrayType->getArraySize())->yes()) { - continue; - } - - $keptTypes[] = $arrayType; - } - if ($keptTypes !== []) { - return $this->create( - $countFuncCall->getArgs()[0]->value, - TypeCombinator::union(...$keptTypes), - $context->negate(), - $scope, - )->setRootExpr($rootExpr); + if ($scope instanceof MutatingScope) { + return $scope->specifyTypesOfNewWorldHandlerNode($expr, $context); } } - $resultTypes = []; - foreach ($type->getArrays() as $arrayType) { - $isSizeSuperTypeOfArraySize = $sizeType->isSuperTypeOf($arrayType->getArraySize()); - if ($isSizeSuperTypeOfArraySize->no()) { - continue; - } - - if ($context->falsey() && $isSizeSuperTypeOfArraySize->maybe()) { - continue; - } - - $resultTypes[] = $isList->yes() - ? $arrayType->truncateListToSize($sizeType) - : TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); - } - - if ($context->truthy() && $isConstantArray->yes() && $isList->yes()) { - $hasOptionalKeysOrUnsealed = false; - foreach ($type->getConstantArrays() as $arrayType) { - if ($arrayType->getOptionalKeys() !== [] || $arrayType->isUnsealed()->yes()) { - // Unsealed CATs can't be narrowed via the - // `HasOffsetValueType`-only shortcut below — the - // intersection of an unsealed shape with a single-slot - // constraint produces `NeverType`. Fall through to - // the full builder-based narrowing, which carries the - // unsealed slot via the loop above. - $hasOptionalKeysOrUnsealed = true; - break; - } - } - - if (!$hasOptionalKeysOrUnsealed) { - $argExpr = $countFuncCall->getArgs()[0]->value; - $argExprString = $this->exprPrinter->printExpr($argExpr); - - $sizeMin = null; - $sizeMax = null; - if ($sizeType instanceof ConstantIntegerType) { - $sizeMin = $sizeType->getValue(); - $sizeMax = $sizeType->getValue(); - } elseif ($sizeType instanceof IntegerRangeType) { - $sizeMin = $sizeType->getMin(); - $sizeMax = $sizeType->getMax(); - } - - $sureTypes = []; - $sureNotTypes = []; - - if ($sizeMin !== null && $sizeMin >= 1) { - $sureTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMin - 1), new MixedType())]; - } - if ($sizeMax !== null) { - $sureNotTypes[$argExprString] = [$argExpr, new HasOffsetValueType(new ConstantIntegerType($sizeMax), new MixedType())]; - } - - if ($sureTypes !== [] || $sureNotTypes !== []) { - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($rootExpr); - } - } - } - - return $this->create($countFuncCall->getArgs()[0]->value, TypeCombinator::union(...$resultTypes), $context, $scope)->setRootExpr($rootExpr); + return $this->specifyDefaultTypes($scope, $expr, $context); } /** @@ -269,230 +107,6 @@ public function handleDefaultTruthyOrFalseyContext(TypeSpecifierContext $context return (new SpecifiedTypes([], []))->setRootExpr($expr); } - /** @internal */ - public function specifyTypesFromConditionalReturnType( - TypeSpecifierContext $context, - Expr\CallLike $call, - ParametersAcceptor $parametersAcceptor, - Scope $scope, - ): ?SpecifiedTypes - { - if (!$parametersAcceptor instanceof ResolvedFunctionVariant) { - return null; - } - - $returnType = $parametersAcceptor->getOriginalParametersAcceptor()->getReturnType(); - if (!$returnType instanceof ConditionalTypeForParameter) { - return null; - } - - if ($context->true()) { - $leftType = new ConstantBooleanType(true); - $rightType = new ConstantBooleanType(false); - } elseif ($context->false()) { - $leftType = new ConstantBooleanType(false); - $rightType = new ConstantBooleanType(true); - } elseif ($context->null()) { - $leftType = new MixedType(); - $rightType = new NeverType(); - } else { - return null; - } - - $argumentExpr = null; - $parameters = $parametersAcceptor->getParameters(); - foreach ($call->getArgs() as $i => $arg) { - if ($arg->unpack) { - continue; - } - - if ($arg->name !== null) { - $paramName = $arg->name->toString(); - } elseif (isset($parameters[$i])) { - $paramName = $parameters[$i]->getName(); - } else { - continue; - } - - if ($returnType->getParameterName() !== '$' . $paramName) { - continue; - } - - $argumentExpr = $arg->value; - } - - if ($argumentExpr === null) { - return null; - } - - return $this->getConditionalSpecifiedTypes($returnType, $leftType, $rightType, $scope, $argumentExpr); - } - - private function getConditionalSpecifiedTypes( - ConditionalTypeForParameter $conditionalType, - Type $leftType, - Type $rightType, - Scope $scope, - Expr $argumentExpr, - ): ?SpecifiedTypes - { - $targetType = $conditionalType->getTarget(); - $ifType = $conditionalType->getIf(); - $elseType = $conditionalType->getElse(); - - if ( - ( - $argumentExpr instanceof Node\Scalar - || ($argumentExpr instanceof ConstFetch && in_array(strtolower($argumentExpr->name->toString()), ['true', 'false', 'null'], true)) - ) && ($ifType instanceof NeverType || $elseType instanceof NeverType) - ) { - return null; - } - - if ($leftType->isSuperTypeOf($ifType)->yes() && $rightType->isSuperTypeOf($elseType)->yes()) { - $context = $conditionalType->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(); - } elseif ($leftType->isSuperTypeOf($elseType)->yes() && $rightType->isSuperTypeOf($ifType)->yes()) { - $context = $conditionalType->isNegated() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); - } else { - return null; - } - - $specifiedTypes = $this->create( - $argumentExpr, - $targetType, - $context, - $scope, - ); - - if ($targetType instanceof ConstantBooleanType) { - if (!$targetType->getValue()) { - $context = $context->negate(); - } - - $specifiedTypes = $specifiedTypes->unionWith($this->specifyTypesInCondition($scope, $argumentExpr, $context)); - } - - return $specifiedTypes; - } - - /** @internal */ - public function specifyTypesFromAsserts(TypeSpecifierContext $context, Expr\CallLike $call, Assertions $assertions, ParametersAcceptor $parametersAcceptor, Scope $scope): ?SpecifiedTypes - { - if ($context->null()) { - $asserts = $assertions->getAsserts(); - } elseif ($context->true()) { - $asserts = $assertions->getAssertsIfTrue(); - } elseif ($context->false()) { - $asserts = $assertions->getAssertsIfFalse(); - } else { - throw new ShouldNotHappenException(); - } - - if (count($asserts) === 0) { - return null; - } - - $argsMap = []; - $parameters = $parametersAcceptor->getParameters(); - foreach ($call->getArgs() as $i => $arg) { - if ($arg->unpack) { - continue; - } - - if ($arg->name !== null) { - $paramName = $arg->name->toString(); - } elseif (isset($parameters[$i])) { - $paramName = $parameters[$i]->getName(); - } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { - $lastParameter = array_last($parameters); - $paramName = $lastParameter->getName(); - } else { - continue; - } - - $argsMap[$paramName][] = $arg->value; - } - foreach ($parameters as $parameter) { - $name = $parameter->getName(); - $defaultValue = $parameter->getDefaultValue(); - if (isset($argsMap[$name]) || $defaultValue === null) { - continue; - } - $argsMap[$name][] = new TypeExpr($defaultValue); - } - - if ($call instanceof MethodCall) { - $argsMap['this'] = [$call->var]; - } - - /** @var SpecifiedTypes|null $types */ - $types = null; - - foreach ($asserts as $assert) { - foreach ($argsMap[substr($assert->getParameter()->getParameterName(), 1)] ?? [] as $parameterExpr) { - $assertedType = TypeTraverser::map($assert->getType(), static function (Type $type, callable $traverse) use ($argsMap, $scope): Type { - if ($type instanceof ConditionalTypeForParameter) { - $parameterName = substr($type->getParameterName(), 1); - if (array_key_exists($parameterName, $argsMap)) { - $type = $traverse($type); - if ($type instanceof ConditionalTypeForParameter) { - $argType = TypeCombinator::union(...array_map(static fn (Expr $expr) => $scope->getType($expr), $argsMap[substr($type->getParameterName(), 1)])); - return $type->toConditional($argType); - } - return $type; - } - } - - return $traverse($type); - }); - - $assertExpr = $assert->getParameter()->getExpr($parameterExpr); - - $templateTypeMap = $parametersAcceptor->getResolvedTemplateTypeMap(); - $containsUnresolvedTemplate = false; - TypeTraverser::map( - $assert->getOriginalType(), - static function (Type $type, callable $traverse) use ($templateTypeMap, &$containsUnresolvedTemplate) { - if ($type instanceof TemplateType && $type->getScope()->getClassName() !== null) { - $resolvedType = $templateTypeMap->getType($type->getName()); - if ($resolvedType === null || $type->getBound()->equals($resolvedType)) { - $containsUnresolvedTemplate = true; - return $type; - } - } - - return $traverse($type); - }, - ); - - $newTypes = $this->create( - $assertExpr, - $assertedType, - $assert->isNegated() ? TypeSpecifierContext::createFalse() : TypeSpecifierContext::createTrue(), - $scope, - )->setRootExpr($containsUnresolvedTemplate || $assert->isEquality() ? $call : null); - $types = $types !== null ? $types->unionWith($newTypes) : $newTypes; - - if (!$context->null() || !$assertedType instanceof ConstantBooleanType) { - continue; - } - - $subContext = $assertedType->getValue() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createFalse(); - if ($assert->isNegated()) { - $subContext = $subContext->negate(); - } - - $types = $types->unionWith($this->specifyTypesInCondition( - $scope, - $assertExpr, - $subContext, - )); - } - } - - return $types; - } - /** * @api */ @@ -508,11 +122,6 @@ public function create( } $specifiedExprs = []; - if ($expr instanceof AlwaysRememberedExpr) { - $specifiedExprs[] = $expr; - $expr = $expr->expr; - } - if ($expr instanceof Expr\Assign) { $specifiedExprs[] = $expr->var; $specifiedExprs[] = $expr->expr; @@ -549,10 +158,15 @@ private function createForExpr( Scope $scope, ): SpecifiedTypes { - if ($context->true()) { - $containsNull = !$type->isNull()->no() && !$scope->getType($expr)->isNull()->no(); - } elseif ($context->false()) { - $containsNull = !TypeCombinator::containsNull($type) && !$scope->getType($expr)->isNull()->no(); + // the null-containment probe only feeds the nullsafe-shortcircuit unwrap + // and createNullsafeTypes() - both are no-ops for a bare variable, so the + // probe (and its type ask) is skipped for one + if (!$expr instanceof Expr\Variable) { + if ($context->true()) { + $containsNull = !$type->isNull()->no() && !$scope->getType($expr)->isNull()->no(); + } elseif ($context->false()) { + $containsNull = !TypeCombinator::containsNull($type) && !$scope->getType($expr)->isNull()->no(); + } } $originalExpr = $expr; diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 93f72eeaa08..3dd5d2e4838 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -113,7 +113,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getByType(PhpDocInheritanceResolver::class), self::getContainer()->getByType(FileHelper::class), - $typeSpecifier, $readWritePropertiesExtensions !== [] ? new DirectExtensionsCollection($readWritePropertiesExtensions) : self::getContainer()->getExtensionsCollection(ReadWritePropertiesExtension::class), self::getContainer()->getExtensionsCollection(FunctionParameterClosureThisExtension::class), self::getContainer()->getExtensionsCollection(MethodParameterClosureThisExtension::class), diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index 19a5fdb5cd9..c9f0c802f62 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -88,7 +88,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getByType(FileTypeMapper::class), $container->getByType(PhpDocInheritanceResolver::class), $container->getByType(FileHelper::class), - $typeSpecifier, $container->getExtensionsCollection(ReadWritePropertiesExtension::class), $container->getExtensionsCollection(FunctionParameterClosureThisExtension::class), $container->getExtensionsCollection(MethodParameterClosureThisExtension::class), diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 2eb025d5d81..407ed72ec5c 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -39,12 +39,14 @@ use function array_merge; use function assert; use function count; +use function getenv; use function is_string; use function sprintf; use function str_replace; use function strtoupper; use function substr; use const PHP_OS; +use const PHP_VERSION_ID; class AnalyserTest extends PHPStanTestCase { @@ -815,7 +817,12 @@ private function createAnalyser(): Analyser $fileTypeMapper = $container->getByType(FileTypeMapper::class); $phpDocInheritanceResolver = new PhpDocInheritanceResolver($fileTypeMapper); - $nodeScopeResolver = new NodeScopeResolver( + $nodeScopeResolverClassName = NodeScopeResolver::class; + if (PHP_VERSION_ID >= 80100 && getenv('PHPSTAN_FNSR') !== '0') { + $nodeScopeResolverClassName = Fiber\FiberNodeScopeResolver::class; + } + + $nodeScopeResolver = new $nodeScopeResolverClassName( $container, $reflectionProvider, $container->getByType(InitializerExprTypeResolver::class), @@ -828,7 +835,6 @@ private function createAnalyser(): Analyser $fileTypeMapper, $phpDocInheritanceResolver, $fileHelper, - $typeSpecifier, $container->getExtensionsCollection(ReadWritePropertiesExtension::class), $container->getExtensionsCollection(FunctionParameterClosureThisExtension::class), $container->getExtensionsCollection(MethodParameterClosureThisExtension::class), diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index 43bdfbbcccb..1efd5d1bb72 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -133,7 +133,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getByType(PhpDocInheritanceResolver::class), self::getContainer()->getByType(FileHelper::class), - $typeSpecifier, $readWritePropertiesExtensions !== [] ? new DirectExtensionsCollection($readWritePropertiesExtensions) : self::getContainer()->getExtensionsCollection(ReadWritePropertiesExtension::class), self::getContainer()->getExtensionsCollection(FunctionParameterClosureThisExtension::class), self::getContainer()->getExtensionsCollection(MethodParameterClosureThisExtension::class), diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index 2499b9aaa06..7d2f4e1e9ac 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -66,7 +66,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getByType(FileTypeMapper::class), $container->getByType(PhpDocInheritanceResolver::class), $container->getByType(FileHelper::class), - $typeSpecifier, $container->getExtensionsCollection(ReadWritePropertiesExtension::class), $container->getExtensionsCollection(FunctionParameterClosureThisExtension::class), $container->getExtensionsCollection(MethodParameterClosureThisExtension::class), diff --git a/tests/PHPStan/Analyser/ReturnStatementsNodeSyntheticAskRule.php b/tests/PHPStan/Analyser/ReturnStatementsNodeSyntheticAskRule.php new file mode 100644 index 00000000000..97fcf8ff45f --- /dev/null +++ b/tests/PHPStan/Analyser/ReturnStatementsNodeSyntheticAskRule.php @@ -0,0 +1,51 @@ + + */ +class ReturnStatementsNodeSyntheticAskRule implements Rule +{ + + public function getNodeType(): string + { + return ReturnStatementsNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node instanceof MethodReturnStatementsNode && !$node instanceof FunctionReturnStatementsNode) { + return []; + } + + $synthetic = new FuncCall(new Name('strlen'), [new Arg(new String_('abc'))]); + $type = $scope->getType($synthetic); + + return [ + RuleErrorBuilder::message(sprintf( + '%s: %s', + $node instanceof MethodReturnStatementsNode ? 'method' : 'function', + $type->describe(VerbosityLevel::precise()), + ))->identifier('tests.syntheticAsk')->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/ReturnStatementsNodeSyntheticAskRuleTest.php b/tests/PHPStan/Analyser/ReturnStatementsNodeSyntheticAskRuleTest.php new file mode 100644 index 00000000000..9df97733543 --- /dev/null +++ b/tests/PHPStan/Analyser/ReturnStatementsNodeSyntheticAskRuleTest.php @@ -0,0 +1,33 @@ + + */ +class ReturnStatementsNodeSyntheticAskRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ReturnStatementsNodeSyntheticAskRule(); + } + + public function testSyntheticAskFromReturnStatementsNode(): void + { + $this->analyse([__DIR__ . '/data/return-statements-synthetic-ask.php'], [ + [ + 'function: 3', + 5, + ], + [ + 'method: 3', + 13, + ], + ]); + } + +} diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index 8444c43e6f7..20567b364fe 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -1197,7 +1197,7 @@ public static function dataCondition(): iterable new Identical(new Expr\ConstFetch(new Name('null')), new Variable('a')), ), ['$a' => 'non-empty-string|null'], - ['$a' => '~null & mixed~non-empty-string'], + ['$a' => 'mixed~non-empty-string & ~null'], ], [ new Expr\BinaryOp\BooleanOr( @@ -1327,7 +1327,10 @@ public static function dataCondition(): iterable ], [ new Expr\NullsafeMethodCall(new Variable('fooOrNull'), new Identifier('doFoo')), - ['$fooOrNull' => '~null'], + [ + '$fooOrNull' => '~null', + '$fooOrNull?->doFoo()' => '~0|0.0|\'\'|\'0\'|array{}|false|null', + ], [], ], [ @@ -1348,7 +1351,10 @@ public static function dataCondition(): iterable new ConstFetch(new Name('true')), ), ['$fooOrNull?->doFoo()' => '~true'], - ['$fooOrNull' => '~null'], + [ + '$fooOrNull?->doFoo()' => 'true & ~0|0.0|\'\'|\'0\'|array{}|false|null', + '$fooOrNull' => '~null', + ], ], [ new NotIdentical( @@ -1385,10 +1391,6 @@ private function toReadableResult(SpecifiedTypes $specifiedTypes): array $typesDescription[$exprString][] = $exprType->describe(VerbosityLevel::precise()); } - foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$exprNode, $exprType]) { - $typesDescription[$exprString][] = '~' . $exprType->describe(VerbosityLevel::precise()); - } - foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$exprNode, $terms]) { // evaluate the alternative-form entry against the test scope, the // same way applySpecifiedTypes() evaluates it at the application @@ -1401,6 +1403,10 @@ private function toReadableResult(SpecifiedTypes $specifiedTypes): array $typesDescription[$exprString][] = TypeCombinator::union(...$parts)->describe(VerbosityLevel::precise()); } + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$exprNode, $exprType]) { + $typesDescription[$exprString][] = '~' . $exprType->describe(VerbosityLevel::precise()); + } + $descriptions = []; foreach ($typesDescription as $exprString => $exprTypes) { $descriptions[$exprString] = implode(' & ', $exprTypes); diff --git a/tests/PHPStan/Analyser/data/return-statements-synthetic-ask.php b/tests/PHPStan/Analyser/data/return-statements-synthetic-ask.php new file mode 100644 index 00000000000..8900173ef5b --- /dev/null +++ b/tests/PHPStan/Analyser/data/return-statements-synthetic-ask.php @@ -0,0 +1,18 @@ +', $x); + assertType('int<0, max>', $y); $v[$k] = $x; if ($x >= $n && $y >= $m) { diff --git a/tests/PHPStan/Rules/Classes/ClassConstantPhp74RuleTest.php b/tests/PHPStan/Rules/Classes/ClassConstantPhp74RuleTest.php new file mode 100644 index 00000000000..071a6c0e86a --- /dev/null +++ b/tests/PHPStan/Rules/Classes/ClassConstantPhp74RuleTest.php @@ -0,0 +1,68 @@ + + */ +class ClassConstantPhp74RuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + $reflectionProvider = self::createReflectionProvider(); + $container = self::getContainer(); + return new ClassConstantRule( + $reflectionProvider, + new RuleLevelHelper( + $reflectionProvider, + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: true, + checkImplicitMixed: true, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ), + new ClassNameCheck( + new ClassCaseSensitivityCheck($reflectionProvider, checkInternalClassCaseSensitivity: true), + new ClassForbiddenNameCheck($container->getExtensionsCollection(ForbiddenClassNameExtension::class)), + $reflectionProvider, + $container->getExtensionsCollection(RestrictedClassNameUsageExtension::class), + ), + $container->getByType(PhpVersion::class), + checkNonStringableDynamicAccess: true, + ); + } + + public function testClassConstantOnExpressionInDeadBranch(): void + { + // the `mixed` typehint parses as an unknown class before PHP 8.0, so + // both branches are dead - the ::class version error must still be + // reported from the narrowed (object) expression there + $this->analyse([__DIR__ . '/data/class-constant-on-expr-never.php'], [ + [ + 'Accessing ::class constant on an expression is supported only on PHP 8.0 and later.', + 11, + ], + ]); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/classConstantPhp74.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Classes/classConstantPhp74.neon b/tests/PHPStan/Rules/Classes/classConstantPhp74.neon new file mode 100644 index 00000000000..768a996d23f --- /dev/null +++ b/tests/PHPStan/Rules/Classes/classConstantPhp74.neon @@ -0,0 +1,5 @@ +includes: + - ../../../../conf/bleedingEdge.neon + +parameters: + phpVersion: 70400 # PHP 7.4 diff --git a/tests/PHPStan/Rules/Classes/data/class-constant-on-expr-never.php b/tests/PHPStan/Rules/Classes/data/class-constant-on-expr-never.php new file mode 100644 index 00000000000..6c12451dd49 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/class-constant-on-expr-never.php @@ -0,0 +1,21 @@ += 8.0 + +namespace ClassConstantOnExprNever; + +class HelloWorld +{ + public function formatCallable(mixed $callable): string + { + if (\is_array($callable)) { + if (\is_object($callable[0])) { + return \sprintf('%s::%s()', $callable[0]::class, $callable[1]); + } + + if (is_string($callable[0])) { + return \sprintf('%s::%s()', $callable[0], $callable[1]); + } + } + + return ''; + } +} From f33d64ed7dd97ed4876cf50b936e10b42bb6f43c Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:37 +0200 Subject: [PATCH 18/32] Drop resolveType() and specifyTypes() from ExprHandler Every handler now expresses its type and narrowing through the callbacks on its ExpressionResult; the interface methods have no implementations or callers left. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/ExprHandler.php | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/Analyser/ExprHandler.php b/src/Analyser/ExprHandler.php index 5e57b94e2e0..4f793ad5e15 100644 --- a/src/Analyser/ExprHandler.php +++ b/src/Analyser/ExprHandler.php @@ -6,7 +6,6 @@ use PhpParser\Node\Expr; use PhpParser\Node\Stmt; use PHPStan\DependencyInjection\ExtensionInterface; -use PHPStan\Type\Type; /** * @template T of Expr @@ -34,19 +33,4 @@ public function processExpr( ExpressionContext $context, ): ExpressionResult; - /** - * @param T $expr - */ - public function resolveType(MutatingScope $scope, Expr $expr): Type; - - /** - * @param T $expr - */ - public function specifyTypes( - TypeSpecifier $typeSpecifier, - Scope $scope, - Expr $expr, - TypeSpecifierContext $context, - ): SpecifiedTypes; - } From 911bfebe160914e076047543ee6ac725fcf89107 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:38 +0200 Subject: [PATCH 19/32] Short-circuit ScopeOps scans over conditional expressions The conditional-expression group scan validates the first holder and re-prints its expression for the invalidation key instead of trusting the group map key, and nodeKey() loses the keepVoid suffix now that void projection happens at the value-read boundary. The native ScopeOps twin mirrors the change and its member order is re-synced with the PHP side; the keepVoid interned string leaves the native key printer too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HdWRs1EV9iFZ84N6QPMU8f --- src/Analyser/ScopeOps.php | 257 +++++++++++++++++------------------- turbo-ext/src/ScopeOps.cpp | 262 ++++++++++++++++++------------------- turbo-ext/src/support.cpp | 14 -- turbo-ext/src/support.h | 1 - 4 files changed, 252 insertions(+), 282 deletions(-) diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index 69eb65a4a94..44e6496fdc3 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -78,10 +78,6 @@ public static function nodeKey(Expr $node, ExprPrinter $exprPrinter): string $key .= '*/'; } - if (($attributes[MutatingScope::KEEP_VOID_ATTRIBUTE_NAME] ?? null) === true) { - $key .= '/*' . MutatingScope::KEEP_VOID_ATTRIBUTE_NAME . '*/'; - } - return $key; } @@ -538,54 +534,93 @@ public static function createConditionalExpressions( } /** - * Depth-first pre-order search for the invalidated expression, replacing a - * NodeFinder::findFirst() call - this runs for every (stored expression, - * invalidated expression) pair whose keys pass the substring pre-filter, - * so the traverser/visitor machinery overhead was significant. + * The scan of MutatingScope::invalidateMethodsOnExpression(): drops tracked + * MethodCall expressions whose var matches the invalidated key, or returns + * null when nothing changed. * - * @param class-string $expressionToInvalidateClass + * @param array $expressionTypes + * @param array $nativeExpressionTypes + * @return array{array, array}|null */ - private static function containsExpressionToInvalidate(Scope $scope, ExprPrinter $exprPrinter, Node $node, string $expressionToInvalidateClass, string $exprStringToInvalidate): bool + public static function invalidateMethodsOnExpression( + ExprPrinter $exprPrinter, + string $exprStringToInvalidate, + array $expressionTypes, + array $nativeExpressionTypes, + ): ?array { - if ( - $exprStringToInvalidate === '$this' - && $node instanceof Name - && ( - in_array($node->toLowerString(), ['self', 'static', 'parent'], true) - || ($scope->getClassReflection() !== null && $scope->getClassReflection()->is($scope->resolveName($node))) - ) - ) { - return true; + $invalidated = false; + + // Same compositional-key shortcut as in invalidateExpressionEntries(): a method + // call's key embeds its receiver's key verbatim, so when the invalidated key + // does not occur in the entry's key, the receiver cannot match and the entry + // can be kept without re-printing the receiver. + $canUseKeyPrefilter = !self::keyMayHideSubExpressions($exprStringToInvalidate) + && !str_contains($exprStringToInvalidate, '/*'); + + foreach ($expressionTypes as $exprString => $exprTypeHolder) { + $exprString = (string) $exprString; // @phpstan-ignore cast.useless + if ( + $canUseKeyPrefilter + && !str_contains($exprString, $exprStringToInvalidate) + && !self::keyMayHideSubExpressions($exprString) + ) { + continue; + } + $expr = $exprTypeHolder->getExpr(); + if (!$expr instanceof MethodCall) { + continue; + } + + if (self::nodeKey($expr->var, $exprPrinter) !== $exprStringToInvalidate) { + continue; + } + + unset($expressionTypes[$exprString]); + unset($nativeExpressionTypes[$exprString]); + $invalidated = true; } - if ( - $node instanceof $expressionToInvalidateClass - && self::nodeKey($node, $exprPrinter) === $exprStringToInvalidate - ) { - return true; + if (!$invalidated) { + return null; } - foreach ($node->getSubNodeNames() as $subNodeName) { - $subNode = $node->$subNodeName; - if ($subNode instanceof Node) { - if (self::containsExpressionToInvalidate($scope, $exprPrinter, $subNode, $expressionToInvalidateClass, $exprStringToInvalidate)) { - return true; - } - } elseif (is_array($subNode)) { - foreach ($subNode as $subNodeItem) { - if ( - $subNodeItem instanceof Node - && self::containsExpressionToInvalidate($scope, $exprPrinter, $subNodeItem, $expressionToInvalidateClass, $exprStringToInvalidate) - ) { - return true; - } + return [$expressionTypes, $nativeExpressionTypes]; + } + + /** + * Whether the compositional-key prefilter cannot be trusted for this key: a + * `__phpstan...` virtual-node key outside the known compositional prefixes + * may hide sub-expressions whose printed form does not occur in the key. + */ + private static function keyMayHideSubExpressions(string $exprString): bool + { + $offset = 0; + while (($pos = strpos($exprString, '__phpstan', $offset)) !== false) { + foreach (self::COMPOSITIONAL_VIRTUAL_KEY_PREFIXES as $prefix) { + if (substr_compare($exprString, $prefix, $pos, strlen($prefix)) === 0) { + $offset = $pos + strlen($prefix); + continue 2; } } + + return true; } return false; } + public static function getIntertwinedRefRootVariableName(Expr $expr): ?string + { + if ($expr instanceof Variable && is_string($expr->name)) { + return $expr->name; + } + if ($expr instanceof Expr\ArrayDimFetch) { + return self::getIntertwinedRefRootVariableName($expr->var); + } + return null; + } + /** * The scan of MutatingScope::invalidateExpression(): computes the tables * with the invalidated entries removed, or null when nothing changed. @@ -609,16 +644,16 @@ public static function invalidateExpressionEntries( { $invalidated = false; - // Mirrors the compositional-key shortcut in shouldInvalidateExpression(): outside - // the carve-outs listed there, a key that does not contain the invalidated key as - // a substring cannot belong to an expression containing the invalidated one, so - // the much more expensive per-expression check can be skipped without being called. + // Compositional-key shortcut mirroring shouldInvalidateExpression(): outside + // the carve-outs, a key that does not contain the invalidated key as a + // substring cannot belong to an expression containing the invalidated one, + // so the per-expression check can be skipped without being called. $canUseKeyPrefilter = $exprStringToInvalidate !== '$this' && !self::keyMayHideSubExpressions($exprStringToInvalidate) && !str_contains($exprStringToInvalidate, '/*'); foreach ($expressionTypes as $exprString => $exprTypeHolder) { - $exprString = (string) $exprString; + $exprString = (string) $exprString; // @phpstan-ignore cast.useless if ( $canUseKeyPrefilter && !str_contains($exprString, $exprStringToInvalidate) @@ -626,8 +661,7 @@ public static function invalidateExpressionEntries( ) { continue; } - $exprExpr = $exprTypeHolder->getExpr(); - if (!self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $exprExpr, $exprString, $requireMoreCharacters, $invalidatingClass)) { + if (!self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $exprTypeHolder->getExpr(), $exprString, $requireMoreCharacters, $invalidatingClass)) { continue; } @@ -641,17 +675,14 @@ public static function invalidateExpressionEntries( if (count($holders) === 0) { continue; } - // Entries are keyed by the printed form of their target expression (see the - // ConditionalExpressionHolder creation sites), so the key doubles as the - // target's node key and there is no need to re-print the expression here. $conditionalExprString = (string) $conditionalExprString; // @phpstan-ignore cast.useless if ( !$canUseKeyPrefilter || str_contains($conditionalExprString, $exprStringToInvalidate) || self::keyMayHideSubExpressions($conditionalExprString) ) { - $firstExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); - if (self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $firstExpr, $conditionalExprString, $requireMoreCharacters, $invalidatingClass)) { + $firstHolder = $holders[array_key_first($holders)]->getTypeHolder(); + if (self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $firstHolder->getExpr(), self::nodeKey($firstHolder->getExpr(), $exprPrinter), $requireMoreCharacters, $invalidatingClass)) { $invalidated = true; continue; } @@ -681,7 +712,7 @@ public static function invalidateExpressionEntries( $shouldKeep = true; $conditionalTypeHolders = $holder->getConditionExpressionTypeHolders(); foreach ($conditionalTypeHolders as $conditionalTypeHolderExprString => $conditionalTypeHolder) { - if (self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $conditionalTypeHolder->getExpr(), (string) $conditionalTypeHolderExprString, invalidatingClass: $invalidatingClass)) { + if (self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $conditionalTypeHolder->getExpr(), $conditionalTypeHolderExprString, invalidatingClass: $invalidatingClass)) { $invalidated = true; $shouldKeep = false; break; @@ -717,83 +748,49 @@ public static function invalidateExpressionEntries( } /** - * The scan of MutatingScope::invalidateMethodsOnExpression(): drops tracked - * MethodCall expressions whose var matches the invalidated key, or returns - * null when nothing changed. + * Depth-first pre-order search for the invalidated expression, replacing a + * NodeFinder::findFirst() call - this runs for every (stored expression, + * invalidated expression) pair whose keys pass the substring pre-filter, + * so the traverser/visitor machinery overhead was significant. * - * @param array $expressionTypes - * @param array $nativeExpressionTypes - * @return array{array, array}|null + * @param class-string $expressionToInvalidateClass */ - public static function invalidateMethodsOnExpression( - ExprPrinter $exprPrinter, - string $exprStringToInvalidate, - array $expressionTypes, - array $nativeExpressionTypes, - ): ?array + private static function containsExpressionToInvalidate(Scope $scope, ExprPrinter $exprPrinter, Node $node, string $expressionToInvalidateClass, string $exprStringToInvalidate): bool { - $invalidated = false; - - // Same compositional-key shortcut as in invalidateExpressionEntries(): a method - // call's key embeds its receiver's key verbatim, so when the invalidated key - // does not occur in the entry's key, the receiver cannot match and the entry - // can be kept without re-printing the receiver. - $canUseKeyPrefilter = !self::keyMayHideSubExpressions($exprStringToInvalidate) - && !str_contains($exprStringToInvalidate, '/*'); - - foreach ($expressionTypes as $exprString => $exprTypeHolder) { - $exprString = (string) $exprString; // @phpstan-ignore cast.useless - if ( - $canUseKeyPrefilter - && !str_contains($exprString, $exprStringToInvalidate) - && !self::keyMayHideSubExpressions($exprString) - ) { - continue; - } - $expr = $exprTypeHolder->getExpr(); - if (!$expr instanceof MethodCall) { - continue; - } - - if (self::nodeKey($expr->var, $exprPrinter) !== $exprStringToInvalidate) { - continue; - } - - unset($expressionTypes[$exprString]); - unset($nativeExpressionTypes[$exprString]); - $invalidated = true; + if ( + $exprStringToInvalidate === '$this' + && $node instanceof Name + && ( + in_array($node->toLowerString(), ['self', 'static', 'parent'], true) + || ($scope->getClassReflection() !== null && $scope->getClassReflection()->is($scope->resolveName($node))) + ) + ) { + return true; } - if (!$invalidated) { - return null; + if ( + $node instanceof $expressionToInvalidateClass + && self::nodeKey($node, $exprPrinter) === $exprStringToInvalidate + ) { + return true; } - return [$expressionTypes, $nativeExpressionTypes]; - } - - /** - * Whether an expression key may textually hide the content of its sub-expressions. - * - * The standard printer is compositional - the key of any sub-expression appears - * verbatim as a substring of the key of the expression containing it - but most - * PHPStan virtual nodes (printed as '__phpstan...') are not: e.g. a wrapped - * variable can be printed by name only. The wrappers in - * COMPOSITIONAL_VIRTUAL_KEY_PREFIXES are the exceptions - they print all of - * their children verbatim - so only a '__phpstan' occurrence that does not - * start one of them signals a possibly non-compositional key. - */ - private static function keyMayHideSubExpressions(string $exprString): bool - { - $offset = 0; - while (($pos = strpos($exprString, '__phpstan', $offset)) !== false) { - foreach (self::COMPOSITIONAL_VIRTUAL_KEY_PREFIXES as $prefix) { - if (substr_compare($exprString, $prefix, $pos, strlen($prefix)) === 0) { - $offset = $pos + strlen($prefix); - continue 2; + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->$subNodeName; + if ($subNode instanceof Node) { + if (self::containsExpressionToInvalidate($scope, $exprPrinter, $subNode, $expressionToInvalidateClass, $exprStringToInvalidate)) { + return true; + } + } elseif (is_array($subNode)) { + foreach ($subNode as $subNodeItem) { + if ( + $subNodeItem instanceof Node + && self::containsExpressionToInvalidate($scope, $exprPrinter, $subNodeItem, $expressionToInvalidateClass, $exprStringToInvalidate) + ) { + return true; + } } } - - return true; } return false; @@ -834,17 +831,16 @@ public static function shouldInvalidateExpression(MutatingScope $scope, ExprPrin return $exprStringToInvalidate === $exprString; } - // getNodeKey() is the pretty-printed expression, and the standard printer is + // nodeKey() is the pretty-printed expression, and the standard printer is // compositional: the key of any sub-expression appears verbatim as a substring of // the key of the expression containing it. So if the invalidated expression's key // does not appear anywhere in this expression's key, this expression cannot contain - // it and we can skip the expensive AST traversal below. + // it and we can skip the containment check below. // Carve-outs where that invariant does not hold: // - '$this' is special-cased in the visitor to also match self/static/parent, - // - most PHPStan virtual nodes (printed as '__phpstan…') use non-compositional - // printers (e.g. a wrapped variable is printed by name, not as '$name') - - // see keyMayHideSubExpressions() for the compositional exceptions, - // - keys carrying a getNodeKey() suffix ('/*…*/') are not plain substrings. + // - PHPStan's virtual nodes (printed as '__phpstan…') use non-compositional printers + // (e.g. a wrapped variable is printed by name, not as '$name'), + // - keys carrying a nodeKey() suffix ('/*…*/') are not plain substrings. if ( $exprStringToInvalidate !== '$this' && !self::keyMayHideSubExpressions($exprStringToInvalidate) @@ -878,17 +874,6 @@ public static function shouldInvalidateExpression(MutatingScope $scope, ExprPrin return true; } - public static function getIntertwinedRefRootVariableName(Expr $expr): ?string - { - if ($expr instanceof Variable && is_string($expr->name)) { - return $expr->name; - } - if ($expr instanceof Expr\ArrayDimFetch) { - return self::getIntertwinedRefRootVariableName($expr->var); - } - return null; - } - /** * The conditional-expressions fixed-point matching of * MutatingScope::applySpecifiedTypes(). diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index e7f434ae5d9..cb9b8d9b2e9 100644 --- a/turbo-ext/src/ScopeOps.cpp +++ b/turbo-ext/src/ScopeOps.cpp @@ -763,6 +763,78 @@ class ScopeOps return zv::Arr::copyOfTable(conditional.table()); } + /* + * Mirrors ScopeOps::invalidateMethodsOnExpression(): drops tracked + * MethodCall expressions whose var matches the invalidated key. Returns + * null when nothing changed. + */ + static zv::Val invalidateMethodsOnExpression(zval *exprPrinter, zend_string *exprStringToInvalidate, zv::TableRef expressionTypes, zv::TableRef nativeExpressionTypes) + { + zend_class_entry *methodCallCe = pt_class(PT_CLASS_METHOD_CALL); + if (UNEXPECTED(methodCallCe == NULL)) { + return zv::Val(); + } + + bool invalidated = false; + zv::Arr resultExpr, resultNative; /* stay UNDEF until the first hit */ + + /* Same compositional-key shortcut as in invalidateExpressionEntries(): a + * method call's key embeds its receiver's key verbatim, so when the + * invalidated key does not occur in the entry's key, the receiver cannot + * match and the entry can be kept without re-printing the receiver. */ + const bool canUseKeyPrefilter = !keyMayHideSubExpressions(exprStringToInvalidate) + && !strContains(exprStringToInvalidate, "/*", 2); + + for (auto entry : expressionTypes) { + zend_string *entryKey = entry.stringKeyOrNull(); + if (canUseKeyPrefilter && entryKey != NULL + && !strContainsStr(entryKey, exprStringToInvalidate) + && !keyMayHideSubExpressions(entryKey)) { + continue; + } + zv::Ref holder = entry.value().deref(); + if (UNEXPECTED(!pt_check_holder(holder.raw()))) { + return zv::Val(); + } + zend_object *expr = holderExpr(holder); + if (!instanceof_function(expr->ce, methodCallCe)) { + continue; + } + int32_t varOffset = pt_instance_prop_offset(expr->ce, "var", sizeof("var") - 1); + if (varOffset < 0) { + continue; + } + zv::Ref var = zv::ObjRef(expr).propAtOffset((uint32_t) varOffset).deref(); + if (!var.isObject()) { + continue; + } + zv::Str varKey = zv::Str::adopt(pt_node_key(var.asObject(), exprPrinter)); + if (UNEXPECTED(varKey.isNull())) { + return zv::Val(); + } + if (!zend_string_equals(varKey.get(), exprStringToInvalidate)) { + continue; + } + + if (resultExpr.isUndef()) { + resultExpr = zv::Arr::adoptTable(zend_array_dup(expressionTypes.table())); + resultNative = zv::Arr::adoptTable(zend_array_dup(nativeExpressionTypes.table())); + } + pt_ht_del(resultExpr.table(), entry.stringKeyOrNull(), entry.indexKey()); + pt_ht_del(resultNative.table(), entry.stringKeyOrNull(), entry.indexKey()); + invalidated = true; + } + + if (!invalidated) { + return zv::Val::null(); + } + + zv::Arr result = zv::Arr::create(2); + result.push(std::move(resultExpr)); + result.push(std::move(resultNative)); + return zv::Val(std::move(result)); + } + /* * Mirrors ScopeOps::invalidateExpressionEntries(). Returns * [expressionTypes, nativeExpressionTypes, conditionalExpressions] with @@ -852,13 +924,9 @@ class ScopeOps continue; } - /* first holder's type-holder expr decides whole-group invalidation; - * entries are keyed by the printed form of their target expression - * (see the ConditionalExpressionHolder creation sites), so the key - * doubles as the target's node key: it feeds the same substring - * gate as a prefilter and there is no need to re-print the - * expression for the full check */ - if (!canUseKeyPrefilter || key == NULL + /* first holder's type-holder expr decides whole-group invalidation */ + if (!canUseKeyPrefilter + || key == NULL || strContainsStr(key, exprStringToInvalidate) || keyMayHideSubExpressions(key)) { zv::Ref firstHolder = (*holdersTable.begin()).value().deref(); @@ -866,13 +934,17 @@ class ScopeOps zend_type_error("phpstan_turbo: expected ConditionalExpressionHolder"); return zv::Val(); } - zend_object *firstExpr = holderExpr(zv::ObjRef(firstHolder.asObject()).propAt(PT_CEH_PROP_TYPEHOLDER)); - zend_string *entryKey = key != NULL ? key : zend_long_to_str((zend_long) idx); - bool failed = false; - bool drop = shouldInvalidate(query, entryKey, firstExpr, requireMoreCharacters, &failed); - if (key == NULL) { - zend_string_release(entryKey); + zv::Ref firstTypeHolder = zv::ObjRef(firstHolder.asObject()).propAt(PT_CEH_PROP_TYPEHOLDER).deref(); + if (UNEXPECTED(!pt_check_holder(firstTypeHolder.raw()))) { + return zv::Val(); + } + zend_object *firstExpr = holderExpr(firstTypeHolder); + zv::Str firstKey = zv::Str::adopt(pt_node_key(firstExpr, exprPrinter)); + if (UNEXPECTED(firstKey.isNull())) { + return zv::Val(); } + bool failed = false; + bool drop = shouldInvalidate(query, firstKey.get(), firstExpr, requireMoreCharacters, &failed); if (UNEXPECTED(failed)) { return zv::Val(); } @@ -987,78 +1059,6 @@ class ScopeOps return zv::Val(std::move(result)); } - /* - * Mirrors ScopeOps::invalidateMethodsOnExpression(): drops tracked - * MethodCall expressions whose var matches the invalidated key. Returns - * null when nothing changed. - */ - static zv::Val invalidateMethodsOnExpression(zval *exprPrinter, zend_string *exprStringToInvalidate, zv::TableRef expressionTypes, zv::TableRef nativeExpressionTypes) - { - zend_class_entry *methodCallCe = pt_class(PT_CLASS_METHOD_CALL); - if (UNEXPECTED(methodCallCe == NULL)) { - return zv::Val(); - } - - bool invalidated = false; - zv::Arr resultExpr, resultNative; /* stay UNDEF until the first hit */ - - /* Same compositional-key shortcut as in invalidateExpressionEntries(): a - * method call's key embeds its receiver's key verbatim, so when the - * invalidated key does not occur in the entry's key, the receiver cannot - * match and the entry can be kept without re-printing the receiver. */ - const bool canUseKeyPrefilter = !keyMayHideSubExpressions(exprStringToInvalidate) - && !strContains(exprStringToInvalidate, "/*", 2); - - for (auto entry : expressionTypes) { - zend_string *entryKey = entry.stringKeyOrNull(); - if (canUseKeyPrefilter && entryKey != NULL - && !strContainsStr(entryKey, exprStringToInvalidate) - && !keyMayHideSubExpressions(entryKey)) { - continue; - } - zv::Ref holder = entry.value().deref(); - if (UNEXPECTED(!pt_check_holder(holder.raw()))) { - return zv::Val(); - } - zend_object *expr = holderExpr(holder); - if (!instanceof_function(expr->ce, methodCallCe)) { - continue; - } - int32_t varOffset = pt_instance_prop_offset(expr->ce, "var", sizeof("var") - 1); - if (varOffset < 0) { - continue; - } - zv::Ref var = zv::ObjRef(expr).propAtOffset((uint32_t) varOffset).deref(); - if (!var.isObject()) { - continue; - } - zv::Str varKey = zv::Str::adopt(pt_node_key(var.asObject(), exprPrinter)); - if (UNEXPECTED(varKey.isNull())) { - return zv::Val(); - } - if (!zend_string_equals(varKey.get(), exprStringToInvalidate)) { - continue; - } - - if (resultExpr.isUndef()) { - resultExpr = zv::Arr::adoptTable(zend_array_dup(expressionTypes.table())); - resultNative = zv::Arr::adoptTable(zend_array_dup(nativeExpressionTypes.table())); - } - pt_ht_del(resultExpr.table(), entry.stringKeyOrNull(), entry.indexKey()); - pt_ht_del(resultNative.table(), entry.stringKeyOrNull(), entry.indexKey()); - invalidated = true; - } - - if (!invalidated) { - return zv::Val::null(); - } - - zv::Arr result = zv::Arr::create(2); - result.push(std::move(resultExpr)); - result.push(std::move(resultNative)); - return zv::Val(std::move(result)); - } - /* Mirrors ScopeOps::shouldInvalidateExpression(). */ static bool shouldInvalidateExpression(zval *scope, zval *exprPrinter, zend_string *exprStringToInvalidate, zval *exprToInvalidate, zend_object *expr, zend_string *exprString, bool requireMoreCharacters, zval *invalidatingClass, bool *failed) { @@ -1229,17 +1229,6 @@ class ScopeOps } private: - /* Everything shouldInvalidate() needs to know about one invalidation. */ - struct InvalidationQuery - { - zval *scope; - zval *exprPrinter; - zend_string *exprStringToInvalidate; - zval *expressionToInvalidate; - zval *invalidatingClass; /* may be NULL */ - bool isThis; - }; - static zv::Val trinarySingleton(zend_long value) { return zv::Val::copyOf(zv::Ref(pt_trinary_singleton(value))); @@ -1594,6 +1583,17 @@ class ScopeOps return true; } + /* Everything shouldInvalidate() needs to know about one invalidation. */ + struct InvalidationQuery + { + zval *scope; + zval *exprPrinter; + zend_string *exprStringToInvalidate; + zval *expressionToInvalidate; + zval *invalidatingClass; /* may be NULL */ + bool isThis; + }; + static bool strContains(zend_string *haystack, const char *needle, size_t len) { return zend_memnstr(ZSTR_VAL(haystack), needle, len, ZSTR_VAL(haystack) + ZSTR_LEN(haystack)) != NULL; @@ -1645,41 +1645,6 @@ class ScopeOps && zend_memnstr(ZSTR_VAL(haystack), ZSTR_VAL(needle), ZSTR_LEN(needle), ZSTR_VAL(haystack) + ZSTR_LEN(haystack)) != NULL; } - /* getIntertwinedRefRootVariableName()'s walk; returns a borrowed string */ - static zend_string *intertwinedRootVariableName(zend_object *expr) - { - zend_class_entry *variableCe = pt_class(PT_CLASS_VARIABLE); - zend_class_entry *arrayDimFetchCe = pt_class(PT_CLASS_ARRAY_DIM_FETCH); - - if (UNEXPECTED(variableCe == NULL || arrayDimFetchCe == NULL)) { - return NULL; - } - - for (;;) { - if (instanceof_function(expr->ce, variableCe)) { - pt_node_class_info *info = pt_get_node_class_info(expr->ce); - if (info == NULL || info->name_offset < 0) { - return NULL; - } - zv::Ref name = zv::ObjRef(expr).propAtOffset((uint32_t) info->name_offset).deref(); - return name.isString() ? name.asString() : NULL; /* borrowed */ - } - if (instanceof_function(expr->ce, arrayDimFetchCe)) { - int32_t varOffset = pt_instance_prop_offset(expr->ce, "var", sizeof("var") - 1); - if (varOffset < 0) { - return NULL; - } - zv::Ref var = zv::ObjRef(expr).propAtOffset((uint32_t) varOffset).deref(); - if (!var.isObject()) { - return NULL; - } - expr = var.asObject(); - continue; - } - return NULL; - } - } - /* shouldInvalidate()'s per-node callback for pt_find_first_recursive() */ static bool invalidationMatcher(zend_object *node, void *rawCtx) { @@ -1929,6 +1894,41 @@ class ScopeOps return true; } + /* getIntertwinedRefRootVariableName()'s walk; returns a borrowed string */ + static zend_string *intertwinedRootVariableName(zend_object *expr) + { + zend_class_entry *variableCe = pt_class(PT_CLASS_VARIABLE); + zend_class_entry *arrayDimFetchCe = pt_class(PT_CLASS_ARRAY_DIM_FETCH); + + if (UNEXPECTED(variableCe == NULL || arrayDimFetchCe == NULL)) { + return NULL; + } + + for (;;) { + if (instanceof_function(expr->ce, variableCe)) { + pt_node_class_info *info = pt_get_node_class_info(expr->ce); + if (info == NULL || info->name_offset < 0) { + return NULL; + } + zv::Ref name = zv::ObjRef(expr).propAtOffset((uint32_t) info->name_offset).deref(); + return name.isString() ? name.asString() : NULL; /* borrowed */ + } + if (instanceof_function(expr->ce, arrayDimFetchCe)) { + int32_t varOffset = pt_instance_prop_offset(expr->ce, "var", sizeof("var") - 1); + if (varOffset < 0) { + return NULL; + } + zv::Ref var = zv::ObjRef(expr).propAtOffset((uint32_t) varOffset).deref(); + if (!var.isObject()) { + return NULL; + } + expr = var.asObject(); + continue; + } + return NULL; + } + } + /* * matchConditionalExpressions()' shared tail of both passes: * $conditions[$exprString][] = $conditionalExpression and diff --git a/turbo-ext/src/support.cpp b/turbo-ext/src/support.cpp index 0fad5118070..6f2ff142996 100644 --- a/turbo-ext/src/support.cpp +++ b/turbo-ext/src/support.cpp @@ -133,7 +133,6 @@ zend_string *pt_str_cache_printer = nullptr; zend_string *pt_str_contains_super_global = nullptr; zend_string *pt_str_array_map_args = nullptr; zend_string *pt_str_start_file_pos = nullptr; -zend_string *pt_str_keep_void = nullptr; static bool pt_strs_inited = false; static HashTable pt_node_class_cache; @@ -148,7 +147,6 @@ void pt_init_strs() pt_str_contains_super_global = zend_string_init("containsSuperGlobal", sizeof("containsSuperGlobal") - 1, 0); pt_str_array_map_args = zend_string_init("arrayMapArgs", sizeof("arrayMapArgs") - 1, 0); pt_str_start_file_pos = zend_string_init("startFilePos", sizeof("startFilePos") - 1, 0); - pt_str_keep_void = zend_string_init("keepVoid", sizeof("keepVoid") - 1, 0); pt_strs_inited = true; } @@ -196,7 +194,6 @@ void pt_support_rshutdown() zend_string_release(pt_str_contains_super_global); zend_string_release(pt_str_array_map_args); zend_string_release(pt_str_start_file_pos); - zend_string_release(pt_str_keep_void); pt_strs_inited = false; } if (pt_node_class_cache_inited) { @@ -644,17 +641,6 @@ zend_string *pt_node_key(zend_object *node, zval *expr_printer) } } - { - zval *keep_void = pt_node_attribute(node, pt_str_keep_void); - if (keep_void != NULL && Z_TYPE_P(keep_void) == IS_TRUE) { - smart_str str = {}; - smart_str_append(&str, key); - smart_str_appendl(&str, "/*keepVoid*/", sizeof("/*keepVoid*/") - 1); - zend_string_release(key); - key = smart_str_extract(&str); - } - } - return key; } diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index 77c08a77081..ba296a33148 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -275,7 +275,6 @@ extern zend_string *pt_str_cache_printer; extern zend_string *pt_str_contains_super_global; extern zend_string *pt_str_array_map_args; extern zend_string *pt_str_start_file_pos; -extern zend_string *pt_str_keep_void; void pt_init_strs(); zval *pt_node_attribute(zend_object *node, zend_string *name); From 613afb6202ff867c742d7b130ac95a8536db218e Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 19:11:46 +0200 Subject: [PATCH 20/32] 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 d8cbf328203..821b6d7c9b6 100644 --- a/src/Turbo/TurboExtensionEnabler.php +++ b/src/Turbo/TurboExtensionEnabler.php @@ -20,7 +20,7 @@ final class TurboExtensionEnabler * version is the short SHA of the last commit touching turbo-ext/src/, * enforced by the phar.yml turbo-version job. */ - public const EXPECTED_EXTENSION_VERSION = '531d6bd'; + public const EXPECTED_EXTENSION_VERSION = '911bfeb'; private static bool $typeCombinatorCacheEnabled = false; From 2728efb12653d197faa1c739c38ff5deea640dac Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 21:22:29 +0200 Subject: [PATCH 21/32] Emit node callbacks after the node's results are stored Rules and DependencyResolver receive a node's callback and immediately ask about the node or its subexpressions. Under fibers a pre-order callback parks on its first ask and resumes when the natural walk stores the result anyway - but a synchronously invoked callback (the plain resolver on PHP < 8.1) re-walked everything it asked about through the on-demand bridge: ~380k re-walks during self-analysis, +15% user CPU vs fibers. Expression nodes now emit their callback right after the handler's result is stored, and the expression-carrying statements (echo, return, expression statements) after their expressions are processed - in both cases with the scope captured at the entry position, so rules observe the same (scope, answer) pair as before. Self-analysis on the plain resolver drops from 470k to 107k on-demand walks; fibers are unchanged. --- src/Analyser/NodeScopeResolver.php | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index a99a00e1689..e301a79b677 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -954,7 +954,15 @@ public function processStmtNode( $stmtScope = $this->processStmtVarAnnotation($scope, $storage, $stmt, $stmt->expr, $nodeCallback); } - $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); + // Statements whose work is processing their expressions emit their node + // callback AFTER that processing, inside their branches below, with the + // entry scope - a synchronously invoked rule (the plain resolver, + // PHP < 8.1) then finds the expressions' results in the storage instead + // of re-walking them on demand, mirroring processExprNodeInternal(). + $deferredStmtCallback = $stmt instanceof Return_ || $stmt instanceof Node\Stmt\Expression || $stmt instanceof Echo_; + if (!$deferredStmtCallback) { + $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); + } if ($stmt instanceof Node\Stmt\Declare_) { $hasYield = false; @@ -1326,6 +1334,8 @@ public function processStmtNode( $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); } + $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); + $throwPoints = $overridingThrowPoints ?? $throwPoints; $impurePoints[] = new ImpurePoint($scope, $stmt, 'echo', 'echo', true); return new InternalStatementResult($scope, $hasYield, $isAlwaysTerminating, [], $throwPoints, $impurePoints); @@ -1342,6 +1352,8 @@ public function processStmtNode( $impurePoints = []; } + $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); + return new InternalStatementResult($scope, $hasYield, true, [ new InternalStatementExitPoint($stmt, $scope), ], $overridingThrowPoints ?? $throwPoints, $impurePoints); @@ -1390,6 +1402,7 @@ public function processStmtNode( $hasAssign = true; }, $nodeCallback), ExpressionContext::createTopLevel()); + $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -3468,12 +3481,18 @@ private function processExprNodeInternal( return $expressionResult; } - $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); - $exprHandler = ExprHandlerRegistry::resolve($expr, $this->container); if ($exprHandler !== null) { $expressionResult = $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); $this->storeExpressionResult($storage, $expr, $expressionResult); + // The node's own callback fires AFTER its result is stored, with the + // scope captured before processing. Rules observe the same (scope, + // answer) pair as at a pre-order emission - under fibers a pre-order + // rule parks on its first ask and resumes at this store anyway - but + // a synchronously invoked rule (the plain resolver, PHP < 8.1) now + // finds the node's and its subtree's results in the storage instead + // of re-walking them on demand. + $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); // the call is now processed and stored; emit a virtual node so // impossible-check rules read its specified types from the result // instead of asking the scope before the call node is processed From 217f2f39b621ac61a87d0d40770da65d23458a57 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 21:27:08 +0200 Subject: [PATCH 22/32] Emit condition-statement and assign-target callbacks after their results are stored Continues the previous commit for the remaining synchronous-callback re-walk clusters: if/elseif/switch emit their statement callback right after the condition's result is stored (rules like the constant-condition and boolean-in-condition helpers ask about the condition), and prepareTarget() emits the raw assignment target's callback after the walk composed and stored the target's read result (DependencyResolver and the property rules ask about the target and its receiver). Scopes stay captured at the entry position. Self-analysis on the plain resolver drops from 107k to 79k on-demand walks - 14.6k of them on real nodes, down from 380k before the two commits. --- src/Analyser/ExprHandler/AssignHandler.php | 27 +++++++++++++++++++++- src/Analyser/NodeScopeResolver.php | 7 ++++-- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 9f3a8d793a2..24b3d5ba323 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -518,12 +518,37 @@ public function prepareTarget( ExpressionContext $context, AssignTargetWalkMode $mode, ): PreparedAssignTarget + { + // The raw target's node callback fires after the walk below composed and + // stored the target's read result, with the scope captured at entry - + // a synchronously invoked rule (the plain resolver, PHP < 8.1) then + // answers its asks from the storage instead of re-walking on demand, + // same as NodeScopeResolver::processExprNodeInternal(). + $prepared = $this->doPrepareTarget($nodeScopeResolver, $scope, $storage, $stmt, $var, $assignedExpr, $nodeCallback, $context, $mode); + $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $mode->enterExpressionAssign() ? $scope->enterExpressionAssign($var) : $scope, $storage); + + return $prepared; + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function doPrepareTarget( + NodeScopeResolver $nodeScopeResolver, + MutatingScope $scope, + ExpressionResultStorage $storage, + Node\Stmt $stmt, + Expr $var, + Expr $assignedExpr, + callable $nodeCallback, + ExpressionContext $context, + AssignTargetWalkMode $mode, + ): PreparedAssignTarget { $enterExpressionAssign = $mode->enterExpressionAssign(); $targetReadResult = null; $targetChainResults = []; $beforeScope = $scope; - $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope, $storage); $hasYield = false; $throwPoints = []; $impurePoints = []; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index e301a79b677..594e3d4be4e 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -959,7 +959,8 @@ public function processStmtNode( // entry scope - a synchronously invoked rule (the plain resolver, // PHP < 8.1) then finds the expressions' results in the storage instead // of re-walking them on demand, mirroring processExprNodeInternal(). - $deferredStmtCallback = $stmt instanceof Return_ || $stmt instanceof Node\Stmt\Expression || $stmt instanceof Echo_; + $deferredStmtCallback = $stmt instanceof Return_ || $stmt instanceof Node\Stmt\Expression || $stmt instanceof Echo_ + || $stmt instanceof If_ || $stmt instanceof Switch_; if (!$deferredStmtCallback) { $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); } @@ -1593,6 +1594,7 @@ public function processStmtNode( } } elseif ($stmt instanceof If_) { $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); $conditionType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $ifAlwaysTrue = $conditionType->isTrue()->yes(); $exitPoints = []; @@ -1627,8 +1629,8 @@ public function processStmtNode( $condScope = $scope; foreach ($stmt->elseifs as $elseif) { - $this->callNodeCallback($nodeCallback, $elseif, $scope, $storage); $condResult = $this->processExprNode($stmt, $elseif->cond, $condScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $this->callNodeCallback($nodeCallback, $elseif, $scope, $storage); $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); @@ -2478,6 +2480,7 @@ static function () use ($condResult, $emptyArrayType): Type { ); } elseif ($stmt instanceof Switch_) { $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); $scope = $condResult->getScope(); $scopeForBranches = $scope; $finalScope = null; From efc936e767d759b8b03cc7eb563cd566dc045c51 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 21:33:25 +0200 Subject: [PATCH 23/32] Store boolean results before their virtual nodes and defer the foreach callback The constant-condition rules listening on BooleanAndNode/BooleanOrNode ask about the raw binary expression, and foreach rules about the iteratee. The boolean handlers now store their result before emitting the virtual node (the later store in processExprNodeInternal is an idempotent re-store of the same result), and the foreach statement emits its callback after the iteratee's result is stored, with the entry scope. --- src/Analyser/ExprHandler/BooleanAndHandler.php | 12 +++++++++--- src/Analyser/ExprHandler/BooleanOrHandler.php | 12 +++++++++--- src/Analyser/NodeScopeResolver.php | 3 ++- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 1d6c3d02ac1..1051e059cab 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -55,9 +55,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); } - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $leftTruthyScope), $scope, $storage, $context); - - return $this->expressionResultFactory->create( + $result = $this->expressionResultFactory->create( $leftMergedWithRightScope, beforeScope: $scope, expr: $expr, @@ -109,6 +107,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex static fn (): MutatingScope => $rightResult->getFalseyScope(), ), ); + // store before emitting the virtual node: its rules ask about the raw + // expression, and a synchronously invoked rule (the plain resolver, + // PHP < 8.1) must find the result in the storage instead of re-walking + // it on demand; processExprNodeInternal()'s later store is a no-op + $nodeScopeResolver->storeExpressionResult($storage, $expr, $result); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $leftTruthyScope), $scope, $storage, $context); + + return $result; } } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index 9642863e987..11484facdd6 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -73,9 +73,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftMergedWithRightScope = $leftResult->getScope()->mergeWith($rightResult->getScope()); } - $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $leftFalseyScope), $scope, $storage, $context); - - return $this->expressionResultFactory->create( + $result = $this->expressionResultFactory->create( $leftMergedWithRightScope, beforeScope: $scope, expr: $expr, @@ -129,6 +127,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex static fn (): MutatingScope => $rightResult->getTruthyScope(), ), ); + // store before emitting the virtual node: its rules ask about the raw + // expression, and a synchronously invoked rule (the plain resolver, + // PHP < 8.1) must find the result in the storage instead of re-walking + // it on demand; processExprNodeInternal()'s later store is a no-op + $nodeScopeResolver->storeExpressionResult($storage, $expr, $result); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $leftFalseyScope), $scope, $storage, $context); + + return $result; } } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 594e3d4be4e..d3024157352 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -960,7 +960,7 @@ public function processStmtNode( // PHP < 8.1) then finds the expressions' results in the storage instead // of re-walking them on demand, mirroring processExprNodeInternal(). $deferredStmtCallback = $stmt instanceof Return_ || $stmt instanceof Node\Stmt\Expression || $stmt instanceof Echo_ - || $stmt instanceof If_ || $stmt instanceof Switch_; + || $stmt instanceof If_ || $stmt instanceof Switch_ || $stmt instanceof Foreach_; if (!$deferredStmtCallback) { $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); } @@ -1727,6 +1727,7 @@ public function processStmtNode( $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); } $condResult = $this->processExprNode($stmt, $stmt->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $this->callNodeCallback($nodeCallback, $stmt, $stmtScope, $storage); $throwPoints = $overridingThrowPoints ?? $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); $scope = $condResult->getScope(); From 4703c464ef3440da24343e559121056166bb8ac8 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 22:26:24 +0200 Subject: [PATCH 24/32] Trim per-store and per-gatherer overhead in the fiber resolver Two diffuse per-event costs measured against the plain resolver: the store hook called processPendingFibersForRequestedExpr() for millions of stores although almost none have a pending fiber - an inline empty check skips the call and the object-id lookup; and gatherers received a FiberScope although they are engine code that never asks about types - they get the raw scope now, and the scopes they capture answer later asks through the storage hub like any MutatingScope. --- src/Analyser/Fiber/FiberNodeScopeResolver.php | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 4bf53acc732..46a8deec190 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -60,7 +60,11 @@ public function callNodeCallback( // returns. Only the rule-facing remainder may be deferred to a fiber; // a rule parking on an unsettled expression must not delay gathering. while ($nodeCallback instanceof GatheringNodeCallback) { - ($nodeCallback->getGatherer())($node, $scope->toFiberScope()); + // gatherers are engine code and never ask about types - handing them + // the raw scope skips a FiberScope construction per emission; the + // scopes they capture (return statements, impure points) answer later + // asks through the storage hub like any MutatingScope + ($nodeCallback->getGatherer())($node, $scope); $nodeCallback = $nodeCallback->getInner(); } @@ -92,7 +96,12 @@ public function callNodeCallback( public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { parent::storeExpressionResult($storage, $expr, $expressionResult); - $this->processPendingFibersForRequestedExpr($storage, $expr, $expressionResult); + // almost every store happens with no fiber pending - the empty check + // here is measurably cheaper than the per-store method call and + // object-id lookup for millions of stores + if ($storage->pendingFibers !== []) { + $this->processPendingFibersForRequestedExpr($storage, $expr, $expressionResult); + } } /** From 5ee3a17645f0718fb7cca562491a5492f238695b Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 22:57:14 +0200 Subject: [PATCH 25/32] Prime the storage for constructor return-type and type-specifying extensions Extends the argument priming to the two remaining lazily-invoked extension surfaces: the dynamic static-method return type extensions dispatched for constructors in NewHandler's exactInstantiation() (runs in the typeCallback), and the function/method/static-method type-specifying extensions (run at narrowing-apply time in the specifyTypesCallback). Both can ask Scope::getType() about the call's arguments after the walk's storage frame is no longer current; the primed storage answers those asks from the argument results instead of re-walking on demand. The eager surfaces (throw-type and parameter-out extensions) run during the handler with the walk storage current and need no priming. --- src/Analyser/ExprHandler/FuncCallHandler.php | 20 ++++++--- .../ExprHandler/MethodCallHandler.php | 23 +++++++--- src/Analyser/ExprHandler/NewHandler.php | 44 ++++++++++++------- .../ExprHandler/StaticCallHandler.php | 22 +++++++--- 4 files changed, 75 insertions(+), 34 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index abcf72db241..4e24b237a2f 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -394,6 +394,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nameResult, $resolvedParametersAcceptor, $specifyContext, + $argsResult, ); // A type constraint on a (narrowable, i.e. non-side-effecting, non-first-class) @@ -1147,19 +1148,26 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin * * @param FuncCall $expr */ - private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, FuncCall $normalizedExpr, ?ExpressionResult $nameResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, FuncCall $normalizedExpr, ?ExpressionResult $nameResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context, ?ArgsResult $argsResult = null): SpecifiedTypes { if ($expr->name instanceof Name) { if ($this->reflectionProvider->hasFunction($expr->name, $scope)) { $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); $args = $expr->getArgs(); - foreach ($this->typeSpecifier->getFunctionTypeSpecifyingExtensions() as $extension) { - if (!$extension->isFunctionSupported($functionReflection, $normalizedExpr, $context)) { - continue; - } + // runs lazily at narrowing-apply time - prime the storage with the + // argument results, see MethodCallHandler::specifyTypes() + $popPrimedStorage = $this->storagePrimer->pushPrimedStorage($scope, $args, $argsResult); + try { + foreach ($this->typeSpecifier->getFunctionTypeSpecifyingExtensions() as $extension) { + if (!$extension->isFunctionSupported($functionReflection, $normalizedExpr, $context)) { + continue; + } - return $extension->specifyTypes($functionReflection, $normalizedExpr, $scope, $context); + return $extension->specifyTypes($functionReflection, $normalizedExpr, $scope, $context); + } + } finally { + $popPrimedStorage(); } if (count($args) > 0 && $resolvedParametersAcceptor !== null) { diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 22a39f88850..6cee2803d39 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -14,6 +14,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; @@ -67,6 +68,7 @@ public function __construct( private ExpressionResultFactory $expressionResultFactory, private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, + private DynamicReturnTypeStoragePrimer $storagePrimer, private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { @@ -200,6 +202,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $varResult, $resolvedParametersAcceptor, $specifyContext, + $argsResult, ); // A type constraint on a (narrowable, i.e. non-side-effecting) method call @@ -461,7 +464,7 @@ private function resolveReturnType(MutatingScope $reflectionScope, bool $nativeT * @param MethodCall $expr * @param MethodCall $normalizedExpr */ - private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, Expr $normalizedExpr, ExpressionResult $varResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, Expr $normalizedExpr, ExpressionResult $varResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context, ?ArgsResult $argsResult = null): SpecifiedTypes { if (!$expr->name instanceof Identifier) { return $this->defaultMethodCallNarrowing($scope, $expr, $varResult, $context); @@ -480,12 +483,20 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop && $this->reflectionProvider->hasClass($referencedClasses[0]) ) { $methodClassReflection = $this->reflectionProvider->getClass($referencedClasses[0]); - foreach ($this->typeSpecifier->getMethodTypeSpecifyingExtensionsForClass($methodClassReflection->getName()) as $extension) { - if (!$extension->isMethodSupported($methodReflection, $normalizedExpr, $context)) { - continue; + // runs lazily at narrowing-apply time - prime the storage with the + // argument results so the extensions' Scope::getType() asks about + // the arguments answer from them instead of re-walking on demand + $popPrimedStorage = $this->storagePrimer->pushPrimedStorage($scope, $args, $argsResult); + try { + foreach ($this->typeSpecifier->getMethodTypeSpecifyingExtensionsForClass($methodClassReflection->getName()) as $extension) { + if (!$extension->isMethodSupported($methodReflection, $normalizedExpr, $context)) { + continue; + } + + return $extension->specifyTypes($methodReflection, $normalizedExpr, $scope, $context); } - - return $extension->specifyTypes($methodReflection, $normalizedExpr, $scope, $context); + } finally { + $popPrimedStorage(); } } diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 23c29c75a3d..9360214f232 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -8,6 +8,7 @@ use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; use PhpParser\Node\Stmt; +use PHPStan\Analyser\ArgsResult; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -15,6 +16,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; use PHPStan\Analyser\GatheringNodeCallback; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -89,6 +91,7 @@ public function __construct( private bool $implicitThrows, private ExpressionResultFactory $expressionResultFactory, private DefaultNarrowingHelper $defaultNarrowingHelper, + private DynamicReturnTypeStoragePrimer $storagePrimer, ) { } @@ -241,6 +244,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nativeTypesPromoted ? null : $resolvedParametersAcceptor, $classResult !== null ? ($nativeTypesPromoted ? $classResult->getNativeType() : $classResult->getType()) : null, + $argsResult, ); $specifyTypesCallback = fn (TypeSpecifierContext $specifyContext, bool $nativeTypesPromoted): SpecifiedTypes => $this->specifyTypes( $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, @@ -396,10 +400,10 @@ private function getConstructorThrowPoint(MethodReflection $constructorReflectio * * @param New_ $expr */ - private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor, ?Type $classExprType): Type + private function resolveReturnType(MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor, ?Type $classExprType, ?ArgsResult $argsResult = null): Type { if ($expr->class instanceof Name) { - return $this->exactInstantiation($scope, $expr, $expr->class, $preResolvedAcceptor); + return $this->exactInstantiation($scope, $expr, $expr->class, $preResolvedAcceptor, $argsResult); } if ($expr->class instanceof Node\Stmt\Class_) { $anonymousClassReflection = $this->reflectionProvider->getAnonymousClassReflection($expr->class, $scope); @@ -415,7 +419,7 @@ private function resolveReturnType(MutatingScope $scope, Expr $expr, ?Parameters return $classExprType->getObjectTypeOrClassStringObjectType(); } - private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className, ?ParametersAcceptor $preResolvedAcceptor): Type + private function exactInstantiation(MutatingScope $scope, New_ $node, Name $className, ?ParametersAcceptor $preResolvedAcceptor, ?ArgsResult $argsResult = null): Type { $resolvedClassName = $scope->resolveName($className); $isStatic = false; @@ -469,21 +473,29 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas $normalizedMethodCall = ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $methodCall); if ($normalizedMethodCall !== null) { - foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicStaticMethodReturnTypeExtensionsForClass($classReflection->getName()) as $dynamicStaticMethodReturnTypeExtension) { - if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($constructorMethod)) { - continue; - } + // runs lazily in the typeCallback - prime the storage with the argument + // results so the extensions' Scope::getType() asks about the arguments + // answer from them instead of re-walking on demand + $popPrimedStorage = $this->storagePrimer->pushPrimedStorage($scope, $normalizedMethodCall->getArgs(), $argsResult); + try { + foreach ($this->dynamicReturnTypeExtensionRegistry->getDynamicStaticMethodReturnTypeExtensionsForClass($classReflection->getName()) as $dynamicStaticMethodReturnTypeExtension) { + if (!$dynamicStaticMethodReturnTypeExtension->isStaticMethodSupported($constructorMethod)) { + continue; + } - $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( - $constructorMethod, - $normalizedMethodCall, - $scope, - ); - if ($resolvedType === null) { - continue; - } + $resolvedType = $dynamicStaticMethodReturnTypeExtension->getTypeFromStaticMethodCall( + $constructorMethod, + $normalizedMethodCall, + $scope, + ); + if ($resolvedType === null) { + continue; + } - $resolvedTypes[] = $resolvedType; + $resolvedTypes[] = $resolvedType; + } + } finally { + $popPrimedStorage(); } } diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 2da101609e9..f990665f7d5 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -17,6 +17,7 @@ use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; +use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodCallReturnTypeHelper; use PHPStan\Analyser\ExprHandler\Helper\MethodThrowPointHelper; @@ -73,6 +74,7 @@ public function __construct( private ExpressionResultFactory $expressionResultFactory, private TypeSpecifier $typeSpecifier, private DefaultNarrowingHelper $defaultNarrowingHelper, + private DynamicReturnTypeStoragePrimer $storagePrimer, private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { @@ -286,6 +288,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $classResult, $resolvedParametersAcceptor, $specifyContext, + $argsResult, ); // A type constraint on a (narrowable, i.e. non-side-effecting) static call @@ -519,7 +522,7 @@ private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, Mutatin * @param StaticCall $expr * @param StaticCall $normalizedExpr */ - private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, Expr $normalizedExpr, ?ExpressionResult $classResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context): SpecifiedTypes + private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, Expr $normalizedExpr, ?ExpressionResult $classResult, ?ParametersAcceptor $resolvedParametersAcceptor, TypeSpecifierContext $context, ?ArgsResult $argsResult = null): SpecifiedTypes { if (!$expr->name instanceof Identifier) { return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); @@ -546,12 +549,19 @@ private function specifyTypes(NodeScopeResolver $nodeScopeResolver, MutatingScop && $this->reflectionProvider->hasClass($referencedClasses[0]) ) { $staticMethodClassReflection = $this->reflectionProvider->getClass($referencedClasses[0]); - foreach ($this->typeSpecifier->getStaticMethodTypeSpecifyingExtensionsForClass($staticMethodClassReflection->getName()) as $extension) { - if (!$extension->isStaticMethodSupported($staticMethodReflection, $normalizedExpr, $context)) { - continue; + // runs lazily at narrowing-apply time - prime the storage with the + // argument results, see MethodCallHandler::specifyTypes() + $popPrimedStorage = $this->storagePrimer->pushPrimedStorage($scope, $args, $argsResult); + try { + foreach ($this->typeSpecifier->getStaticMethodTypeSpecifyingExtensionsForClass($staticMethodClassReflection->getName()) as $extension) { + if (!$extension->isStaticMethodSupported($staticMethodReflection, $normalizedExpr, $context)) { + continue; + } + + return $extension->specifyTypes($staticMethodReflection, $normalizedExpr, $scope, $context); } - - return $extension->specifyTypes($staticMethodReflection, $normalizedExpr, $scope, $context); + } finally { + $popPrimedStorage(); } } From 48858c3a7a914d9e2e9f399bd5d5ddda4b107394 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 23:01:25 +0200 Subject: [PATCH 26/32] Sum the output-buffer level without walking a synthetic node OutputBufferHelper priced the incremented ob_get_level() type by walking a synthetic Plus of two TypeExprs through Scope::getType() - a core-engine synthetic re-walk. It is now a service that calls InitializerExprTypeResolver::getPlusType() on the operand types directly. --- src/Analyser/ExprHandler/FuncCallHandler.php | 5 +-- .../ExprHandler/Helper/OutputBufferHelper.php | 35 +++++++++++++------ 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 4e24b237a2f..21017c62471 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -116,6 +116,7 @@ public function __construct( private DefaultNarrowingHelper $defaultNarrowingHelper, private EarlyTerminatingCallHelper $earlyTerminatingHelper, private DynamicReturnTypeStoragePrimer $storagePrimer, + private OutputBufferHelper $outputBufferHelper, private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, ) { @@ -764,9 +765,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->afterOpenSslCall($functionReflection->getName()); } - $outputBufferDelta = $functionReflection !== null ? OutputBufferHelper::getLevelDelta($functionReflection->getName()) : 0; + $outputBufferDelta = $functionReflection !== null ? $this->outputBufferHelper->getLevelDelta($functionReflection->getName()) : 0; if ($outputBufferDelta !== 0) { - $scope = OutputBufferHelper::applyLevelDelta($scope, $outputBufferDelta); + $scope = $this->outputBufferHelper->applyLevelDelta($scope, $outputBufferDelta); } $pureCallable = $parametersAcceptor instanceof CallableParametersAcceptor diff --git a/src/Analyser/ExprHandler/Helper/OutputBufferHelper.php b/src/Analyser/ExprHandler/Helper/OutputBufferHelper.php index 309ad76c9fb..798b7e210f5 100644 --- a/src/Analyser/ExprHandler/Helper/OutputBufferHelper.php +++ b/src/Analyser/ExprHandler/Helper/OutputBufferHelper.php @@ -2,22 +2,31 @@ namespace PHPStan\Analyser\ExprHandler\Helper; -use PhpParser\Node\Expr\BinaryOp; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Name; use PHPStan\Analyser\MutatingScope; +use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\TypeExpr; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\MixedType; +use PHPStan\Type\Type; use function in_array; +#[AutowiredService] final class OutputBufferHelper { + public function __construct(private InitializerExprTypeResolver $initializerExprTypeResolver) + { + } + private const LEVEL_INCREMENTING_FUNCTIONS = ['ob_start']; private const LEVEL_DECREMENTING_FUNCTIONS = ['ob_get_clean', 'ob_get_flush', 'ob_end_clean', 'ob_end_flush']; - public static function getLevelDelta(string $functionName): int + public function getLevelDelta(string $functionName): int { if (in_array($functionName, self::LEVEL_INCREMENTING_FUNCTIONS, true)) { return 1; @@ -30,25 +39,29 @@ public static function getLevelDelta(string $functionName): int return 0; } - public static function applyLevelDelta(MutatingScope $scope, int $delta): MutatingScope + public function applyLevelDelta(MutatingScope $scope, int $delta): MutatingScope { foreach ([new Name('ob_get_level'), new Name\FullyQualified('ob_get_level')] as $name) { $obGetLevelCall = new FuncCall($name, []); $scope = $scope->assignExpression( $obGetLevelCall, - $scope->getType(new BinaryOp\Plus( - new TypeExpr($scope->getType($obGetLevelCall)), - new TypeExpr(new ConstantIntegerType($delta)), - )), - $scope->getType(new BinaryOp\Plus( - new TypeExpr($scope->getNativeType($obGetLevelCall)), - new TypeExpr(new ConstantIntegerType($delta)), - )), + $this->addDelta($scope->getType($obGetLevelCall), $delta), + $this->addDelta($scope->getNativeType($obGetLevelCall), $delta), ); } return $scope; } + /** Sums the tracked level type with the delta without walking a synthetic node. */ + private function addDelta(Type $levelType, int $delta): Type + { + return $this->initializerExprTypeResolver->getPlusType( + new TypeExpr($levelType), + new TypeExpr(new ConstantIntegerType($delta)), + static fn (Expr $expr): Type => $expr instanceof TypeExpr ? $expr->getExprType() : new MixedType(), + ); + } + } From 23adeb875eb7ee92d3cb3ccc4051d1b851562887 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 23:06:44 +0200 Subject: [PATCH 27/32] Compose static-call and clone-with types without synthetic-node walks Two more core synthetic re-walks replaced by the logic they were fishing for: StaticCallHandler priced `new $classExpr` through Scope::getType() to learn what a class-string receiver instantiates - that is getObjectTypeOrClassStringObjectType() on the receiver's own result; and FuncCallHandler's clone-with support walked a synthetic Clone_ although the object argument was just processed - CloneHandler's type logic is now an extracted resolveCloneType() both call sites share. --- src/Analyser/ExprHandler/CloneHandler.php | 11 +++++++---- src/Analyser/ExprHandler/FuncCallHandler.php | 7 +++++-- src/Analyser/ExprHandler/StaticCallHandler.php | 5 ++++- 3 files changed, 16 insertions(+), 7 deletions(-) diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index c890510777e..8f88fc4e7d7 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -35,6 +35,12 @@ public function __construct( { } + /** The type `clone $expr` produces for an operand of the given type. */ + public static function resolveCloneType(Type $exprType): Type + { + return TypeTraverser::map(TypeCombinator::intersect($exprType, new ObjectWithoutClassType()), new CloneTypeTraverser()); + } + public function supports(Expr $expr): bool { return $expr instanceof Clone_; @@ -52,10 +58,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - typeCallback: static function (bool $nativeTypesPromoted) use ($exprResult): Type { - $cloneType = TypeCombinator::intersect(($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), new ObjectWithoutClassType()); - return TypeTraverser::map($cloneType, new CloneTypeTraverser()); - }, + typeCallback: static fn (bool $nativeTypesPromoted): Type => self::resolveCloneType($nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType()), specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } diff --git a/src/Analyser/ExprHandler/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 21017c62471..ad2c84a0816 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -20,6 +20,7 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\CloneHandler; use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ExprHandler\Helper\DynamicReturnTypeStoragePrimer; use PHPStan\Analyser\ExprHandler\Helper\EarlyTerminatingCallHelper; @@ -211,10 +212,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // properties array resolve from stored results instead of unprocessed // nodes; processArgs() below processes them again as clone()'s arguments, // so the NoopNodeCallback here avoids duplicate node-callbacks. - $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $cloneObjectArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[0]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $clonePropertiesArgResult = $nodeScopeResolver->processExprNode($stmt, $normalizedExpr->getArgs()[1]->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); $clonePropertiesArgType = $clonePropertiesArgResult->getType(); - $cloneExpr = new TypeExpr($scope->getType(new Expr\Clone_($normalizedExpr->getArgs()[0]->value))); + // the cloned type is composed from the object argument's result - + // no synthetic Clone_ walk + $cloneExpr = new TypeExpr(CloneHandler::resolveCloneType($cloneObjectArgResult->getType())); $clonePropertiesArgTypeConstantArrays = $clonePropertiesArgType->getConstantArrays(); foreach ($clonePropertiesArgTypeConstantArrays as $clonePropertiesArgTypeConstantArray) { foreach ($clonePropertiesArgTypeConstantArray->getKeyTypes() as $i => $clonePropertyKeyType) { diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index f990665f7d5..eda5b039ce7 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -214,7 +214,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex // already-computed result instead of re-walking via Scope::getType(). $objectClasses = $classResult->getType()->getObjectClassNames(); if (count($objectClasses) !== 1) { - $objectClasses = $scope->getType(new New_($expr->class))->getObjectClassNames(); + // the receiver may be a class-string instead of an object - the + // instantiated type is what `new` would produce, read from the + // same result instead of walking a synthetic New_ node + $objectClasses = $classResult->getType()->getObjectTypeOrClassStringObjectType()->getObjectClassNames(); } if (count($objectClasses) === 1) { $objectExprResult = $nodeScopeResolver->processExprNode($stmt, new StaticCall(new Name($objectClasses[0]), $expr->name, []), $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); From aa49c32b4a903cb94797e7ed71a582b257f171e3 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Fri, 14 Aug 2026 23:09:44 +0200 Subject: [PATCH 28/32] Read $this from scope state and document the parent-instantiation walk The static-call promoted-properties check priced $this through a synthetic Variable walk - it is a plain scope-state read. The parent-instantiation synthetic New_ walk in exactInstantiation() stays: it re-resolves the parent constructor's template types from the arguments, which a direct recursion cannot - now documented at the site. --- src/Analyser/ExprHandler/NewHandler.php | 4 ++++ src/Analyser/ExprHandler/StaticCallHandler.php | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Analyser/ExprHandler/NewHandler.php b/src/Analyser/ExprHandler/NewHandler.php index 9360214f232..8e8465a08c2 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -630,6 +630,10 @@ classReflection: $classReflection->withTypes($types)->asFinal(), } $newParentNode = new New_(new Name($constructorMethod->getDeclaringClass()->getName()), $node->args); + // the synthetic walk is load-bearing: it re-resolves the parent + // constructor's template types from the arguments (processArgs against + // the parent's signature), which a direct exactInstantiation() recursion + // with the child's acceptor cannot do $newParentType = $scope->getType($newParentNode); $newParentTypeClassReflections = $newParentType->getObjectClassReflections(); if (count($newParentTypeClassReflections) !== 1) { diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index eda5b039ce7..c015bec95af 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -388,7 +388,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $scope->isInClass() && $scope->getClassReflection()->isSubclassOfClass($methodReflection->getDeclaringClass()) ) { - $thisType = $scope->getType(new Variable('this')); + $thisType = $scope->getVariableType('this'); $methodClassReflection = $methodReflection->getDeclaringClass(); foreach ($methodClassReflection->getNativeReflection()->getProperties(ReflectionProperty::IS_PUBLIC | ReflectionProperty::IS_PROTECTED) as $property) { if (!$property->isPromoted() || $property->getDeclaringClass()->getName() !== $methodClassReflection->getName()) { From d3dec69b995cc4be76298e66b704c2806c0b2e64 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 15 Aug 2026 10:10:07 +0200 Subject: [PATCH 29/32] Memoize function and constant name resolution in BetterReflectionProvider Resolving an unqualified name probes the namespaced variant first, and a miss surfaces as a constructed-and-thrown IdentifierNotFound inside the reflector - repeated for every re-ask of the same name. The single-pass engine's per-flavour callbacks re-ask the same names many times per file (2,500 exception throws while analysing ConstantArrayTypeTest alone). The resolution is now memoized per (namespace, name as written); the key keeps the asked case because the resolved name preserves it for the incorrect-case rules. --- .../BetterReflectionProvider.php | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index 68bee839746..0c8f20e7e49 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -72,6 +72,12 @@ final class BetterReflectionProvider implements ReflectionProvider /** @var FunctionReflection[] */ private array $functionReflections = []; + /** @var array */ + private array $resolvedFunctionNames = []; + + /** @var array */ + private array $resolvedConstantNames = []; + /** @var ClassReflection[] */ private array $classReflections = []; @@ -365,7 +371,18 @@ public function resolveFunctionName(Node\Name $nameNode, ?NamespaceAnswerer $nam return $name; } - return $this->resolveName($nameNode, function (string $name): bool { + // memoized per (namespace, name AS WRITTEN): the namespaced-fallback + // probe of an unqualified name is exception-driven in the reflector, and + // type callbacks re-ask the same names many times per file. The key is + // case-sensitive - the resolved name preserves the asked case, which the + // incorrect-case rules compare against the canonical one. + $cacheKey = ($namespaceAnswerer !== null ? ($namespaceAnswerer->getNamespace() ?? '') : '') . '::' . (string) $nameNode; + if (array_key_exists($cacheKey, $this->resolvedFunctionNames)) { + $cached = $this->resolvedFunctionNames[$cacheKey]; + return $cached === false ? null : $cached; + } + + $resolved = $this->resolveName($nameNode, function (string $name): bool { try { $this->reflector->reflectFunction($name); return true; @@ -380,6 +397,9 @@ public function resolveFunctionName(Node\Name $nameNode, ?NamespaceAnswerer $nam } return false; }, $namespaceAnswerer); + $this->resolvedFunctionNames[$cacheKey] = $resolved ?? false; + + return $resolved; } public function hasConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): bool @@ -465,7 +485,15 @@ public function getConstant(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAn public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $namespaceAnswerer): ?string { - return $this->resolveName($nameNode, function (string $name): bool { + // memoized per (namespace, name) - see resolveFunctionName(); constants + // are case-sensitive, so the key uses the name as written + $cacheKey = ($namespaceAnswerer !== null ? ($namespaceAnswerer->getNamespace() ?? '') : '') . '::' . (string) $nameNode; + if (array_key_exists($cacheKey, $this->resolvedConstantNames)) { + $cached = $this->resolvedConstantNames[$cacheKey]; + return $cached === false ? null : $cached; + } + + $resolved = $this->resolveName($nameNode, function (string $name): bool { try { $this->reflector->reflectConstant($name); return true; @@ -478,6 +506,9 @@ public function resolveConstantName(Node\Name $nameNode, ?NamespaceAnswerer $nam } return false; }, $namespaceAnswerer); + $this->resolvedConstantNames[$cacheKey] = $resolved ?? false; + + return $resolved; } /** From 34244b20bffccfe1ddc6fba9debe67f67459ea53 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 15 Aug 2026 10:20:38 +0200 Subject: [PATCH 30/32] Restore the non-type-driven fast path for the resolved acceptor The processArgs() restructure lost two things the pre-ArgsResult shape had: the resolved acceptor was selected (and generic-resolved) for every call although a single template-free acceptor IS the resolved acceptor - the fast path the original selectFromArgs() took - and the per-argument type-driven predicate re-traversed the acceptor's parameter types on every argument instead of once per call. Restoring both cuts GenericParametersAcceptorResolver::resolve from 5,175 to 648 calls while analysing ConstantArrayTypeTest. --- src/Analyser/NodeScopeResolver.php | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index d3024157352..1360927bdd2 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -4449,6 +4449,12 @@ public function processArgs( || $namedArgumentsVariants !== null || ($metadataAcceptor !== null && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableType($metadataAcceptor)); + // Both predicates are hoisted out of the per-argument loop - they traverse + // the acceptor's parameter types. + $hasTemplateParameterType = $metadataAcceptor !== null + && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor); + $argMetadataIsTypeDriven = count($parametersAcceptors) > 1 || $hasTemplateParameterType; + $hasYield = false; $throwPoints = []; $impurePoints = []; @@ -4507,10 +4513,7 @@ public function processArgs( } $argMetadataAcceptor = $metadataAcceptor; - if ( - $metadataAcceptor !== null - && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) - ) { + if ($metadataAcceptor !== null && $argMetadataIsTypeDriven) { if ($this->argConsumesResolvedParameterType($arg->value)) { // Resolve the acceptor for this argument from the args gathered SO FAR, padded to the // full argument count with mixed. Closures sort last and by-ref out-params follow the @@ -4929,9 +4932,13 @@ public function processArgs( // scope select (and generic-resolve) the acceptor that drives the call's // return type. Intrinsic overrides are applied on the final scope, // mirroring the original selectFromArgs(). + // When the selection is not type-driven, the single acceptor IS the + // resolved acceptor - the fast path selectFromArgs() used to take. $resolvedAcceptor = null; if ($parametersAcceptors !== []) { - $resolvedAcceptor = $this->selectArgsMetadataAcceptor($args, $gatheredTypes, $parametersAcceptors, $namedArgumentsVariants, $gatheredHasName, $gatheredUnpack, $scope); + $resolvedAcceptor = $typeDrivenAcceptorSelection + ? $this->selectArgsMetadataAcceptor($args, $gatheredTypes, $parametersAcceptors, $namedArgumentsVariants, $gatheredHasName, $gatheredUnpack, $scope) + : $metadataAcceptor; } // The by-ref OUT writeback reads the metadata acceptor: it is selected from @@ -4942,7 +4949,7 @@ public function processArgs( $writebackAcceptor = $metadataAcceptor; if ( $metadataAcceptor !== null - && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) + && $argMetadataIsTypeDriven ) { $writebackAcceptor = $resolvedAcceptor; } From cb69f5eca49af72b4c4943138e5d442ea5cfdca4 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sat, 15 Aug 2026 13:35:54 +0200 Subject: [PATCH 31/32] Degrade on-demand walks of handler-less nodes to mixed A rule asking the type of a virtual node itself (BooleanOrNode, ...) parks its fiber - the node is never stored - and the flush walks it on demand, hitting processExprNodeInternal()'s unhandled-expr throw and aborting the whole file's analysis with an internal error. MutatingScope::resolveType() already answers such nodes with mixed; processExprOnDemand() now takes the same fallback, keeping the main walk's throw for real source nodes. --- src/Analyser/NodeScopeResolver.php | 11 ++++++ .../Analyser/VirtualNodeGetTypeRule.php | 35 +++++++++++++++++++ .../Analyser/VirtualNodeGetTypeRuleTest.php | 29 +++++++++++++++ .../Analyser/data/virtual-node-get-type.php | 8 +++++ 4 files changed, 83 insertions(+) create mode 100644 tests/PHPStan/Analyser/VirtualNodeGetTypeRule.php create mode 100644 tests/PHPStan/Analyser/VirtualNodeGetTypeRuleTest.php create mode 100644 tests/PHPStan/Analyser/data/virtual-node-get-type.php diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 1360927bdd2..08ff61f49a5 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3237,6 +3237,17 @@ public function processExprNodeConsumingStored(Node\Stmt $stmt, Expr $expr, Muta public function processExprOnDemand(Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage): ExpressionResult { + // A node no handler supports - a virtual node (BooleanOrNode, ...) a + // rule asked the type of - degrades to mixed, mirroring + // MutatingScope::resolveType()'s fallback. The main walk's unhandled + // throw stays: real source nodes must have a handler. + if ( + ExprHandlerRegistry::resolve($expr, $this->container) === null + && !($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) + ) { + return $this->createEagerExpressionResult($scope, $expr, new MixedType(), new MixedType()); + } + // save/restore, never reset: on-demand walks nest (a typeCallback // evaluated mid-walk prices another synthetic node) and a hard reset // would turn stored-result consumption off for the rest of the outer diff --git a/tests/PHPStan/Analyser/VirtualNodeGetTypeRule.php b/tests/PHPStan/Analyser/VirtualNodeGetTypeRule.php new file mode 100644 index 00000000000..e5051bb1466 --- /dev/null +++ b/tests/PHPStan/Analyser/VirtualNodeGetTypeRule.php @@ -0,0 +1,35 @@ + + */ +class VirtualNodeGetTypeRule implements Rule +{ + + public function getNodeType(): string + { + return BooleanOrNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return [ + RuleErrorBuilder::message($scope->getType($node)->describe(VerbosityLevel::precise())) + ->identifier('tests.virtualNodeGetType') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/VirtualNodeGetTypeRuleTest.php b/tests/PHPStan/Analyser/VirtualNodeGetTypeRuleTest.php new file mode 100644 index 00000000000..fba8e0f7204 --- /dev/null +++ b/tests/PHPStan/Analyser/VirtualNodeGetTypeRuleTest.php @@ -0,0 +1,29 @@ + + */ +class VirtualNodeGetTypeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new VirtualNodeGetTypeRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/virtual-node-get-type.php'], [ + [ + 'mixed', + 7, + ], + ]); + } + +} diff --git a/tests/PHPStan/Analyser/data/virtual-node-get-type.php b/tests/PHPStan/Analyser/data/virtual-node-get-type.php new file mode 100644 index 00000000000..c38f96e4ad2 --- /dev/null +++ b/tests/PHPStan/Analyser/data/virtual-node-get-type.php @@ -0,0 +1,8 @@ + Date: Sat, 15 Aug 2026 14:18:06 +0200 Subject: [PATCH 32/32] Guard FiberScope stored-result reads with the asking scope's variable state A rule callback may derive the scope it was handed - e.g. assignExpression() pinning a call-site literal onto a parameter variable, the way callback- analysing tooling re-analyses a callee body via the public processNodes() API with more specific argument types. FiberScope's settled-result fast path and post-suspend read returned the naked walk-position type, ignoring such derivations. Both now consume through askScopeVariableStateMatches() in a rule-facing mode: variables unknown to the asking scope and variables narrower at the evaluation position (the coalesce right side priced on the left's falsey branch) leave the walk answer standing; an asker-side refinement re-prices on the asking scope's state. MutatingScope::toFiberScope() seeds the created scope with its origin (a WeakReference - a strong back-reference would cycle with the $fiberScope cache and never free with GC disabled), so toMutatingScope() answers with the walk scope itself and the guard's beforeScope identity check hits for same-position asks. --- src/Analyser/ExpressionResult.php | 25 ++++++++- src/Analyser/Fiber/FiberScope.php | 50 +++++++++++++++-- src/Analyser/MutatingScope.php | 8 ++- .../Analyser/DerivedScopeGetTypeRule.php | 56 +++++++++++++++++++ .../Analyser/DerivedScopeGetTypeRuleTest.php | 29 ++++++++++ .../Analyser/data/derived-scope-get-type.php | 12 ++++ 6 files changed, 174 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/DerivedScopeGetTypeRule.php create mode 100644 tests/PHPStan/Analyser/DerivedScopeGetTypeRuleTest.php create mode 100644 tests/PHPStan/Analyser/data/derived-scope-get-type.php diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 85382970b39..132fd218f0f 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -560,7 +560,17 @@ public function takeReadVariableStateSnapshot(): ReadVariableStateSnapshot return new ReadVariableStateSnapshot($states); } - public function askScopeVariableStateMatches(MutatingScope $scope, bool $useNativeTypes): bool + /** + * $ruleFacingAsk: a FiberScope ask tolerates walk-side divergence - a + * variable the asking scope has no opinion on (born inside the asked node, + * past the ask position) and a variable NARROWER at the evaluation + * position (the coalesce right side priced on the left's falsey branch) + * both leave the walk answer standing; only an asker-side refinement (a + * callback pinning a call-site literal onto a parameter) re-prices. + * Engine-side consumers keep the strict direction: any state divergence - + * including a variable removed since the walk - forces re-pricing. + */ + public function askScopeVariableStateMatches(MutatingScope $scope, bool $useNativeTypes, bool $ruleFacingAsk = false): bool { // same unpromoted position implies same promoted position - skip the // flavour derivation for the common same-position ask @@ -588,6 +598,19 @@ public function askScopeVariableStateMatches(MutatingScope $scope, bool $useNati foreach ($names as $name) { $askKnows = $readScope->hasVariableType($name); $positionKnows = $positionScope->hasVariableType($name); + if ($ruleFacingAsk) { + if ($askKnows->no()) { + continue; + } + if ($positionKnows->no()) { + return false; + } + if ($readScope->getVariableType($name)->isSuperTypeOf($positionScope->getVariableType($name))->yes()) { + continue; + } + + return false; + } if ($askKnows->no() && $positionKnows->no()) { continue; } diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index 46dece54316..3d2bbc27ec1 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -12,6 +12,7 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Type\Type; +use WeakReference; use function array_pop; use function count; @@ -26,17 +27,40 @@ final class FiberScope extends MutatingScope private ?MutatingScope $mutatingScope = null; + /** @var WeakReference|null */ + private ?WeakReference $seededMutatingScope = null; + public function toFiberScope(): self { return $this; } + /** + * Called by MutatingScope::toFiberScope() with the scope this one was + * created from: same state, so it can answer toMutatingScope() directly - + * keeping its resolvedTypes memo and the identity with stored results' + * beforeScope that askScopeVariableStateMatches() short-circuits on. + * Weakly referenced: the origin caches this scope in its $fiberScope, a + * strong back-reference would cycle and never free with GC disabled. + */ + public function seedMutatingScope(MutatingScope $scope): void + { + $this->seededMutatingScope = WeakReference::create($scope); + } + public function toMutatingScope(): MutatingScope { if ($this->mutatingScope !== null) { return $this->mutatingScope; } + if ($this->seededMutatingScope !== null) { + $seeded = $this->seededMutatingScope->get(); + if ($seeded !== null) { + return $seeded; + } + } + return $this->mutatingScope = $this->scopeFactory->toMutatingFactory()->create( $this->context, $this->isDeclareStrictTypes(), @@ -76,7 +100,7 @@ public function getType(Expr $node): Type // hand back - skip the two fiber switches for the stored ask $storedResult = $this->findSettledStoredResult($node); if ($storedResult !== null) { - return $storedResult->getType(); + return $this->getStoredResultTypeOnThisScope($storedResult, $node, false); } } @@ -90,13 +114,31 @@ public function getType(Expr $node): Type && count($this->truthyValueExprs) === 0 && count($this->falseyValueExprs) === 0 ) { - return $expressionResult->getType(); + return $this->getStoredResultTypeOnThisScope($expressionResult, $node, false); } $scope = $this->preprocessScope($expressionResult->getBeforeScope()); return $scope->getType($node); } + /** + * Consumes a stored result guarded by this scope's position instead of + * reading the naked walk-position type: a callback may have derived this + * scope (e.g. assignExpression() pinning a call-site literal onto a + * parameter) and the walk-position memo predates that. On a state match + * the result's own read is the answer; a counterfactual ask re-prices on + * the MutatingScope, mirroring resolveTypeOfNewWorldHandlerNode(). + */ + private function getStoredResultTypeOnThisScope(ExpressionResult $result, Expr $node, bool $useNativeTypes): Type + { + $scope = $this->toMutatingScope(); + if ($result->canResolveOwnType() && $result->askScopeVariableStateMatches($scope, $useNativeTypes, true)) { + return $useNativeTypes ? $result->getNativeType() : $result->getType(); + } + + return $useNativeTypes ? $scope->getNativeType($node) : $scope->getType($node); + } + public function getScopeType(Expr $expr): Type { return $this->toMutatingScope()->getType($expr); @@ -122,7 +164,7 @@ public function getNativeType(Expr $expr): Type ) { $storedResult = $this->findSettledStoredResult($expr); if ($storedResult !== null) { - return $storedResult->getNativeType(); + return $this->getStoredResultTypeOnThisScope($storedResult, $expr, true); } } @@ -136,7 +178,7 @@ public function getNativeType(Expr $expr): Type && count($this->truthyValueExprs) === 0 && count($this->falseyValueExprs) === 0 ) { - return $expressionResult->getNativeType(); + return $this->getStoredResultTypeOnThisScope($expressionResult, $expr, true); } $scope = $this->preprocessScope($expressionResult->getBeforeScope()); diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index db97cc74bbb..2e520512b8c 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -24,6 +24,7 @@ use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; +use PHPStan\Analyser\Fiber\FiberScope; use PHPStan\Analyser\Traverser\TransformStaticTypeTraverser; use PHPStan\Collectors\Collector; use PHPStan\DependencyInjection\Container; @@ -232,7 +233,7 @@ public function toFiberScope(): self return $this->fiberScope; } - return $this->fiberScope = $this->scopeFactory->toFiberFactory()->create( + $fiberScope = $this->scopeFactory->toFiberFactory()->create( $this->context, $this->isDeclareStrictTypes(), $this->getFunction(), @@ -250,6 +251,11 @@ public function toFiberScope(): self $this->parentScope, $this->nativeTypesPromoted, ); + if ($fiberScope instanceof FiberScope) { + $fiberScope->seedMutatingScope($this); + } + + return $this->fiberScope = $fiberScope; } public function toMutatingScope(): self diff --git a/tests/PHPStan/Analyser/DerivedScopeGetTypeRule.php b/tests/PHPStan/Analyser/DerivedScopeGetTypeRule.php new file mode 100644 index 00000000000..38db464ac2e --- /dev/null +++ b/tests/PHPStan/Analyser/DerivedScopeGetTypeRule.php @@ -0,0 +1,56 @@ + + */ +class DerivedScopeGetTypeRule implements Rule +{ + + public function getNodeType(): string + { + return FuncCall::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if (!$node->name instanceof Node\Name || $node->name->toLowerString() !== 'target') { + return []; + } + if (!$scope instanceof MutatingScope) { + throw new ShouldNotHappenException(); + } + + $pinnedType = new ConstantStringType('weight'); + $derivedScope = $scope->assignExpression(new Variable('key'), $pinnedType, $pinnedType); + $argExpr = $node->getArgs()[0]->value; + + return [ + RuleErrorBuilder::message(sprintf( + '%s / %s', + $derivedScope->getType($argExpr)->describe(VerbosityLevel::precise()), + $derivedScope->getNativeType($argExpr)->describe(VerbosityLevel::precise()), + )) + ->identifier('tests.derivedScopeGetType') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/DerivedScopeGetTypeRuleTest.php b/tests/PHPStan/Analyser/DerivedScopeGetTypeRuleTest.php new file mode 100644 index 00000000000..068a0dca1fd --- /dev/null +++ b/tests/PHPStan/Analyser/DerivedScopeGetTypeRuleTest.php @@ -0,0 +1,29 @@ + + */ +class DerivedScopeGetTypeRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new DerivedScopeGetTypeRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/derived-scope-get-type.php'], [ + [ + "'weight' / 'weight'", + 11, + ], + ]); + } + +} diff --git a/tests/PHPStan/Analyser/data/derived-scope-get-type.php b/tests/PHPStan/Analyser/data/derived-scope-get-type.php new file mode 100644 index 00000000000..3991d8588d0 --- /dev/null +++ b/tests/PHPStan/Analyser/data/derived-scope-get-type.php @@ -0,0 +1,12 @@ +