diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 02b99ed61bb..0b5cc52d737 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: 2 + 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/ArgsResult.php b/src/Analyser/ArgsResult.php new file mode 100644 index 00000000000..b53d7325cd7 --- /dev/null +++ b/src/Analyser/ArgsResult.php @@ -0,0 +1,76 @@ + $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(); + } + + public function hasYield(): bool + { + return $this->expressionResult->hasYield(); + } + + public function isAlwaysTerminating(): bool + { + return $this->expressionResult->isAlwaysTerminating(); + } + + /** + * @return InternalThrowPoint[] + */ + public function getThrowPoints(): array + { + return $this->expressionResult->getThrowPoints(); + } + + /** + * @return ImpurePoint[] + */ + public function getImpurePoints(): array + { + return $this->expressionResult->getImpurePoints(); + } + + public function getResolvedParametersAcceptor(): ?ParametersAcceptor + { + return $this->resolvedParametersAcceptor; + } + +} diff --git a/src/Analyser/AssignTargetWalkMode.php b/src/Analyser/AssignTargetWalkMode.php new file mode 100644 index 00000000000..36d677a6f5e --- /dev/null +++ b/src/Analyser/AssignTargetWalkMode.php @@ -0,0 +1,64 @@ +enterExpressionAssign; + } + + public function producesTargetReadResult(): bool + { + return $this->producesTargetReadResult; + } + + public function issetSemanticsForRead(): bool + { + return $this->issetSemanticsForRead; + } + +} diff --git a/src/Analyser/ConditionalExpressionHolderRecipe.php b/src/Analyser/ConditionalExpressionHolderRecipe.php new file mode 100644 index 00000000000..93a9e7b3a83 --- /dev/null +++ b/src/Analyser/ConditionalExpressionHolderRecipe.php @@ -0,0 +1,118 @@ + $conditionEntries [exprString, expr, fromSureTypes, type] + * @param list $holderEntries [exprString, expr, type, target type pinned at compose time (null = read the applying scope)] + */ + public function __construct( + private array $conditionEntries, + private array $holderEntries, + private bool $holdersFromSureTypes, + ) + { + } + + /** + * @return array + */ + public function evaluate(MutatingScope $scope): array + { + $conditionExpressionTypes = []; + $droppedNoOpConditions = []; + // the unnarrowed type of each condition expression, for the + // dropped-self-condition complement below + $conditionOriginalTypes = []; + foreach ($this->conditionEntries as [$exprString, $expr, $fromSureTypes, $type]) { + $scopeType = $scope->getStateType($expr); + $conditionType = $fromSureTypes + ? TypeCombinator::remove($scopeType, $type) + : TypeCombinator::intersect($scopeType, $type); + if ($scopeType->equals($conditionType)) { + $droppedNoOpConditions[$exprString] = true; + continue; + } + + $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes($expr, $conditionType); + $conditionOriginalTypes[$exprString] = $scopeType; + } + + if ($conditionExpressionTypes === []) { + return []; + } + + $holders = []; + foreach ($this->holderEntries as [$exprString, $expr, $type, $pinnedTargetType]) { + // The target's only link to the antecedent was a no-op relation (e.g. + // `$a === $b`) that got dropped, so the antecedent no longer constrains + // it. Projecting a consequent onto it would fire unsoundly. Skip it. + if (array_key_exists($exprString, $droppedNoOpConditions)) { + continue; + } + + $conditions = $conditionExpressionTypes; + $droppedSelfCondition = null; + if (isset($conditions[$exprString])) { + $droppedSelfCondition = $conditions[$exprString]; + unset($conditions[$exprString]); + } + + if ($conditions === []) { + continue; + } + + $targetType = $pinnedTargetType ?? $scope->getStateType($expr); + $holderType = $this->holdersFromSureTypes + ? TypeCombinator::intersect($targetType, $type) + : TypeCombinator::remove($targetType, $type); + + // The dropped self-condition narrowed the target; without it the + // holder must allow the values it excluded, or it over-narrows when + // only the remaining conditions hold. So union back the complement. + if ($droppedSelfCondition !== null) { + $complement = TypeCombinator::remove($conditionOriginalTypes[$exprString], $droppedSelfCondition->getType()); + if (!$complement instanceof NeverType) { + $holderType = TypeCombinator::union($holderType, $complement); + } + } + + // These boolean-decomposition holders only refine an expression's + // type in a future scope; they must never collapse it to never and + // thereby mark the whole scope unreachable. A never result is an + // artifact (e.g. removing a non-nullable property's full type after + // swapping isset() narrowing), not a real contradiction. + if ($holderType instanceof NeverType && !$targetType instanceof NeverType) { + continue; + } + $holder = new ConditionalExpressionHolder( + $conditions, + ExpressionTypeHolder::createYes($expr, $holderType), + ); + $holders[$exprString] ??= []; + $holders[$exprString][$holder->getKey()] = $holder; + } + + return $holders; + } + +} diff --git a/src/Analyser/DeferredSpecifiedTypesAugment.php b/src/Analyser/DeferredSpecifiedTypesAugment.php new file mode 100644 index 00000000000..f5f97d98afd --- /dev/null +++ b/src/Analyser/DeferredSpecifiedTypesAugment.php @@ -0,0 +1,17 @@ +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/DisjunctionBranchUnionAugment.php b/src/Analyser/DisjunctionBranchUnionAugment.php new file mode 100644 index 00000000000..d02849bbeb8 --- /dev/null +++ b/src/Analyser/DisjunctionBranchUnionAugment.php @@ -0,0 +1,73 @@ + $candidates [target expr, left branch type, right branch type] + */ + public function __construct( + private NodeScopeResolver $nodeScopeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private array $candidates, + ) + { + } + + public function evaluate(MutatingScope $scope): ?SpecifiedTypes + { + $result = null; + foreach ($this->candidates as [$targetExpr, $leftType, $rightType]) { + if (!$scope->hasExpressionType($targetExpr)->yes()) { + continue; + } + + $originalType = $this->nodeScopeResolver->readTypeOfMaybeStored($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 + // flows through the operands' exact merge + if (TypeUtils::containsTemplateType($originalType)) { + continue; + } + if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { + continue; + } + + if ($rightType->equals($originalType) || !$originalType->isSuperTypeOf($rightType)->yes()) { + continue; + } + + $unionType = TypeCombinator::union($leftType, $rightType); + // a union that covers the whole original type gains no narrowing - + // pinning it would only stack a redundant intersection on the + // expression (e.g. re-wrapping a template type in its own bound) + if ($unionType->isSuperTypeOf($originalType)->yes()) { + continue; + } + + $created = $this->defaultNarrowingHelper->createForSubject($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope); + $result = $result === null ? $created : $result->unionWith($created); + } + + return $result; + } + +} diff --git a/src/Analyser/DisjunctionHolderProjectionAugment.php b/src/Analyser/DisjunctionHolderProjectionAugment.php new file mode 100644 index 00000000000..9f57b384946 --- /dev/null +++ b/src/Analyser/DisjunctionHolderProjectionAugment.php @@ -0,0 +1,108 @@ + $alternativeKeys expressions the exact either-branch + * merge already constrains - the weaker branch-scope union must not + * be added on top + */ + public function __construct( + private NodeScopeResolver $nodeScopeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private $leftTruthyScope, + private MutatingScope $leftFalseyScope, + private $rightTruthyScope, + private array $alternativeKeys, + ) + { + } + + public function evaluate(MutatingScope $scope): ?SpecifiedTypes + { + $result = null; + $seen = []; + $leftTruthyScope = null; + $rightTruthyScope = null; + foreach ([$scope, $this->leftFalseyScope] as $sourceScope) { + foreach ($sourceScope->getConditionalExpressions() as $rootExprString => $holders) { + if (isset($seen[$rootExprString])) { + continue; + } + if ($holders === []) { + continue; + } + $seen[$rootExprString] = true; + $targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); + + if (isset($this->alternativeKeys[$rootExprString])) { + continue; + } + + // Only project when the target stays Yes-defined in the original + // scope and in both filtered branches. A sure type implicitly + // raises certainty to Yes, which would wrongly upgrade Maybe-defined + // variables — `if (empty($a['bar']))` for instance leaves `$a` + // Maybe-defined because `empty()` tolerates undefined offsets. + if (!$scope->hasExpressionType($targetExpr)->yes()) { + continue; + } + $leftTruthyScope ??= ($this->leftTruthyScope)(); + $rightTruthyScope ??= ($this->rightTruthyScope)(); + if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) { + continue; + } + + $origType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $scope); + + $leftType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftTruthyScope); + $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); + if (!$leftNarrowed) { + continue; + } + + $rightType = $this->nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightTruthyScope); + $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); + if (!$rightNarrowed) { + continue; + } + + $unionType = TypeCombinator::union($leftType, $rightType); + if ($unionType->equals($origType)) { + continue; + } + + $created = $this->defaultNarrowingHelper->createSubjectTypes($scope, $targetExpr, null, $unionType, TypeSpecifierContext::createTrue()); + $result = $result === null ? $created : $result->unionWith($created); + } + } + + return $result; + } + +} 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; - } diff --git a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php index eb89d6c618b..7a69796aac5 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -14,19 +14,19 @@ 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\IssetabilityDescriptor; 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\Expr\TypeExpr; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; +use PHPStan\Type\TypeCombinator; use function array_merge; /** @@ -36,7 +36,10 @@ final class ArrayDimFetchHandler implements ExprHandler { - public function __construct(private ExpressionResultFactory $expressionResultFactory) + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { } @@ -45,40 +48,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; @@ -94,6 +63,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $varResult->isAlwaysTerminating(), 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), ); } @@ -104,6 +77,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $varResult->getScope(); $varType = $varResult->getType(); + $offsetGetResult = null; if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( $stmt, @@ -113,6 +87,15 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex new NoopNodeCallback(), $context, )->getThrowPoints()); + // process the offsetGet here (storage is available, so the result is + // captured - not the storage - avoiding a reference cycle) so the + // typeCallback reads its result instead of Scope::getType(). Gated by + // the same maybe-ArrayAccess condition, so plain arrays never reach it. + $offsetGetResult = $nodeScopeResolver->processExprOnDemand( + new MethodCall($expr->var, new Identifier('offsetGet'), [new Arg($expr->dim)]), + $scope, + $storage, + ); } return $this->expressionResultFactory->create( @@ -123,12 +106,26 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } + containsNullsafe: $varResult->containsNullsafe(), + issetabilityDescriptor: IssetabilityDescriptor::offset($varResult, $dimResult), + typeCallback: static function (bool $nativeTypesPromoted) use ($varResult, $dimResult, $offsetGetResult): Type { + $offsetAccessibleType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + $shortCircuit = static fn (Type $type): Type => $varResult->containsNullsafe() && TypeCombinator::containsNull($offsetAccessibleType) + ? TypeCombinator::addNull($type) + : $type; - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + if ( + $offsetGetResult !== null + && !$offsetAccessibleType->isArray()->yes() + && (new ObjectType(ArrayAccess::class))->isSuperTypeOf($offsetAccessibleType)->yes() + ) { + return $shortCircuit(($nativeTypesPromoted ? $offsetGetResult->getNativeType() : $offsetGetResult->getType())); + } + + return $shortCircuit($offsetAccessibleType->getOffsetValueType(($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()))); + }, + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } 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/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index 5390ee8e212..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, ) { } @@ -41,7 +39,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 { - $result = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); + $arrowFunctionResult = $nodeScopeResolver->processArrowFunctionNode($stmt, $expr, $scope, $storage, $nodeCallback, null); + $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(), @@ -51,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/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index f5b30e63bbe..99489914fed 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -22,6 +22,7 @@ use PhpParser\Node\Expr\Variable; use PhpParser\Node\Name; use PhpParser\Node\Stmt; +use PHPStan\Analyser\AssignTargetWalkMode; use PHPStan\Analyser\ConditionalExpressionHolder; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; @@ -29,14 +30,18 @@ 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\NonNullabilityHelper; +use PHPStan\Analyser\ExprHandler\Helper\VirtualExprResultHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\NoopNodeCallback; +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; @@ -56,6 +61,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; @@ -92,12 +98,16 @@ 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, ) { } @@ -107,329 +117,801 @@ public function supports(Expr $expr): bool return $expr instanceof Assign || $expr instanceof AssignRef; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - return $scope->getType($expr->expr); - } + $beforeScope = $scope; + $target = $this->prepareTarget( + $nodeScopeResolver, + $scope, + $storage, + $stmt, + $expr->var, + $expr->expr, + $nodeCallback, + $context, + AssignTargetWalkMode::assign(), + ); - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$expr instanceof Assign) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + $valueBeforeScope = $target->getScope(); + $valueScope = $valueBeforeScope; + $valueImpurePoints = []; + if ($expr instanceof AssignRef) { + $referencedExpr = $expr->expr; + while ($referencedExpr instanceof ArrayDimFetch) { + $referencedExpr = $referencedExpr->var; + } + + if ($referencedExpr instanceof PropertyFetch || $referencedExpr instanceof StaticPropertyFetch) { + $valueImpurePoints[] = new ImpurePoint( + $valueScope, + $expr, + 'propertyAssignByRef', + 'property assignment by reference', + false, + ); + } + + $valueScope = $valueScope->enterExpressionAssign($expr->expr); } - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); + $valueContext = $context; + if ($expr->var instanceof Variable && is_string($expr->var->name)) { + $valueContext = $valueContext->enterRightSideAssign( + $expr->var->name, + $expr->expr, + ); } - 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); + $assignedExprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $valueScope, $storage, $nodeCallback, $valueContext->enterDeep()); + $valueImpurePoints = array_merge($valueImpurePoints, $assignedExprResult->getImpurePoints()); + $valueScope = $assignedExprResult->getScope(); + + if ($expr instanceof AssignRef) { + $valueScope = $valueScope->exitExpressionAssign($expr->expr); } - // infer $arr[$key] after $key = array_key_first/last($arr) + $result = $this->applyWrite( + $nodeScopeResolver, + $target, + $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, + $context, + ); + $scope = $result->getScope(); + 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 + $expr instanceof AssignRef + && $expr->var instanceof Variable + && is_string($expr->var->name) + && $expr->expr instanceof Variable + && is_string($expr->expr->name) ) { - $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); + $varName = $expr->var->name; + $refName = $expr->expr->name; + $type = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope); + $nativeType = $nodeScopeResolver->readTypeOfMaybeStored($expr->var, $scope->doNotTreatPhpDocTypesAsCertain()); - 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(); - } + // When $varName is assigned, update $refName + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($varName, new Variable($refName), new Variable($varName)), + $type, + $nativeType, + ); - 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()), - ); - } - } + // When $refName is assigned, update $varName + $scope = $scope->assignExpression( + new IntertwinedVariableByReferenceWithExpr($refName, new Variable($varName), new Variable($refName)), + $type, + $nativeType, + ); + } + + $vars = $nodeScopeResolver->getAssignedVariables($expr->var); + if (count($vars) > 0) { + $varChangedScope = false; + $scope = $nodeScopeResolver->processVarAnnotation($scope, $vars, $stmt, $varChangedScope); + if (!$varChangedScope) { + $scope = $nodeScopeResolver->processStmtVarAnnotation($scope, $storage, $stmt, null, $nodeCallback); } } - // 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(); + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $result->hasYield(), + 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($nodeScopeResolver, $expr, $assignedExprResult, $beforeScope) : fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult, $beforeScope) : null, + ); + } + + /** + * 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(NodeScopeResolver $nodeScopeResolver, Assign $expr, ?ExpressionResult $assignedExprResult, MutatingScope $beforeScope): Closure + { + return function (TypeSpecifierContext $context, bool $nativeTypesPromoted) use ($nodeScopeResolver, $expr, $assignedExprResult, $beforeScope): SpecifiedTypes { + $s = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if ($context->null()) { + $assignedScope = $s->exitFirstLevelStatements(); + $result = $assignedExprResult ?? $assignedScope->obtainResultForNode($expr->expr); + $specifiedTypes = $result->getSpecifiedTypesForScope($assignedScope, $context)->setRootExpr($expr); + $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); + } else { + $specifiedTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr->var, $context)->setRootExpr($expr); } - if ($arrayArg !== null) { - $arrayType = $scope->getType($arrayArg); + // 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 = $nodeScopeResolver->readTypeOfMaybeStored($arrayArg, $s); if ($arrayType->isArray()->yes()) { if ($context->true()) { $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), + $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); - - 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), + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), ); - } 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(); - } + } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { + $keyType = $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $s); + $nonNullKeyType = TypeCombinator::removeNull($keyType); + if (!$nonNullKeyType instanceof NeverType) { $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $narrowedKeyType, $dimFetchType), + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()), ); } } } } - } - if ($context->null()) { - // infer $arr[$key] after $key = array_rand($arr) + // 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() - && in_array($expr->expr->name->toLowerString(), ['array_rand'], true) - && count($expr->expr->getArgs()) >= 1 + && count($expr->expr->getArgs()) >= 2 ) { - $numArg = null; - $args = $expr->expr->getArgs(); - $arrayArg = $args[0]->value; - if (count($args) > 1) { - $numArg = $args[1]->value; + $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 && $nodeScopeResolver->readTypeOfMaybeStored($expr->expr->getArgs()[2]->value, $s)->isTrue()->yes(); + } elseif ($funcName === 'array_find_key') { + $arrayArg = $expr->expr->getArgs()[0]->value; + $sentinelType = new NullType(); } - $one = new ConstantIntegerType(1); - $arrayType = $scope->getType($arrayArg); + if ($arrayArg !== null) { + $arrayType = $nodeScopeResolver->readTypeOfMaybeStored($arrayArg, $s); + + 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 = $nodeScopeResolver->readTypeOfMaybeStored($expr->expr->getArgs()[0]->value, $s); + $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 = $nodeScopeResolver->readTypeOfMaybeStored($expr->expr, $s); + $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); + if (!$narrowedKeyType instanceof NeverType) { + if ($isStrictArraySearch) { + $needleType = $nodeScopeResolver->readTypeOfMaybeStored($expr->expr->getArgs()[0]->value, $s); + $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 ( - $arrayType->isArray()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() - && ($numArg === null || $one->isSuperTypeOf($scope->getType($numArg))->yes()) + $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 ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $numArg = null; + $args = $expr->expr->getArgs(); + $arrayArg = $args[0]->value; + if (count($args) > 1) { + $numArg = $args[1]->value; + } + $one = new ConstantIntegerType(1); + $arrayType = $nodeScopeResolver->readTypeOfMaybeStored($arrayArg, $s); - return $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); + if ( + $arrayType->isArray()->yes() + && $arrayType->isIterableAtLeastOnce()->yes() + && ($numArg === null || $one->isSuperTypeOf($nodeScopeResolver->readTypeOfMaybeStored($numArg, $s))->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 = $scope->getType($arrayArg); + // infer $list[$count] after $count = count($list) - 1 if ( - $arrayType->isList()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() + $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 ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $arrayArg = $expr->expr->left->getArgs()[0]->value; + $arrayType = $nodeScopeResolver->readTypeOfMaybeStored($arrayArg, $s); + 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->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } } } return $specifiedTypes; - } - - return $specifiedTypes; + }; } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + /** + * The pre-value half of an assignment: walks the target's sub-expressions + * (root, dimensions, receiver, dynamic name) in PHP's evaluation order and + * captures everything applyWrite() needs into a PreparedAssignTarget. The + * caller processes the assigned value on PreparedAssignTarget::getScope() + * between the two calls. + * + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + public function prepareTarget( + 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; - $result = $this->processAssignVar( - $nodeScopeResolver, - $scope, - $storage, - $stmt, - $expr->var, - $expr->expr, - $nodeCallback, - $context, - function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { - $beforeScope = $scope; - $impurePoints = []; - if ($expr instanceof AssignRef) { - $referencedExpr = $expr->expr; - while ($referencedExpr instanceof ArrayDimFetch) { - $referencedExpr = $referencedExpr->var; - } + $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope, $storage); + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $isAlwaysTerminating = false; + $isAssignOp = $assignedExpr instanceof Expr\AssignOp && !$enterExpressionAssign; + if ($var instanceof Variable) { + 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, so it would never store its ExpressionResult. Price it here + // as a read so AssignOpHandler's typeCallback consumes the stored + // result instead of pricing the unprocessed lvalue on demand. The + // ??= read happens with isset() semantics (mirroring + // CoalesceHandler's left-side processing, with the isset + // descriptor - bug-13623); the wrapped scope is discarded. + $readScope = $scope; + if ($mode->issetSemanticsForRead()) { + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); + $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); + } + $targetReadResult = $nodeScopeResolver->processExprNode($stmt, $var, $readScope, $storage, new NoopNodeCallback(), $context->enterDeep()); + if ($mode->issetSemanticsForRead()) { + $this->defaultNarrowingHelper->captureChainResults($var, $storage, $targetChainResults); + } + } - if ($referencedExpr instanceof PropertyFetch || $referencedExpr instanceof StaticPropertyFetch) { - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'propertyAssignByRef', - 'property assignment by reference', - false, - ); - } + return new PreparedAssignTarget( + PreparedAssignTarget::KIND_VARIABLE, + $var, + $assignedExpr, + $beforeScope, + $scope, + $enterExpressionAssign, + $isAssignOp, + $hasYield, + $throwPoints, + $impurePoints, + $isAlwaysTerminating, + targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, + ); + } - $scope = $scope->enterExpressionAssign($expr->expr); + if ($var instanceof ArrayDimFetch) { + $dimFetchStack = []; + $originalVar = $var; + $assignedPropertyExpr = $assignedExpr; + while ($var instanceof ArrayDimFetch) { + $varForSetOffsetValue = $var->var; + if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($nodeScopeResolver, $varForSetOffsetValue, $scope)); } - if ($expr->var instanceof Variable && is_string($expr->var->name)) { - $context = $context->enterRightSideAssign( - $expr->var->name, - $expr->expr, + if ( + $var === $originalVar + && $var->dim !== null + && $scope->hasExpressionType($var)->yes() + ) { + $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( + $varForSetOffsetValue, + $var->dim, + $assignedPropertyExpr, + ); + } else { + $assignedPropertyExpr = new SetOffsetValueTypeExpr( + $varForSetOffsetValue, + $var->dim, + $assignedPropertyExpr, ); } + $dimFetchStack[] = $var; + $var = $var->var; + } - $result = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - $hasYield = $result->hasYield(); - $throwPoints = $result->getThrowPoints(); - $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); - $isAlwaysTerminating = $result->isAlwaysTerminating(); - $scope = $result->getScope(); + // 1. eval root expr + // The root is read to obtain the container that receives the offset write, so a + // property root must resolve to its readable type (not its writable one) even + // though it sits on the left-hand side of the assignment. + if ($enterExpressionAssign) { + $scope = $scope->enterExpressionAssign($var, false); + } + $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); + } + + // 2. eval dimensions + $offsetTypes = []; + $offsetNativeTypes = []; + $deferredDimFetchResults = []; + $dimFetchStack = array_reverse($dimFetchStack); + $lastDimKey = array_key_last($dimFetchStack); + foreach ($dimFetchStack as $key => $dimFetch) { + $dimExpr = $dimFetch->dim; - if ($expr instanceof AssignRef) { - $scope = $scope->exitExpressionAssign($expr->expr); + // Callback was already called for last dim at the beginning of the method. + if ($key !== $lastDimKey) { + $nodeScopeResolver->callNodeCallback($nodeCallback, $dimFetch, $enterExpressionAssign ? $scope->enterExpressionAssign($dimFetch) : $scope, $storage); } - return $this->expressionResultFactory->create($scope, $beforeScope, $expr->expr, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); - }, - true, - ); - $scope = $result->getScope(); + if ($dimExpr === null) { + $offsetTypes[] = [null, $dimFetch]; + $offsetNativeTypes[] = [null, $dimFetch]; + $deferredDimFetchResults[] = [$dimFetch, $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $dimFetch, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + )]; + + } else { + if ($enterExpressionAssign) { + $scope->enterExpressionAssign($dimExpr); + } + // 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()); + $offsetTypes[] = [$result->getType(), $dimFetch]; + $offsetNativeTypes[] = [$result->getNativeType(), $dimFetch]; + $hasYield = $hasYield || $result->hasYield(); + $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + + $deferredDimFetchResults[] = [$dimFetch, $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $dimFetch, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $nodeScopeResolver->readTypeOfMaybeStored($dimFetch->var, $scope->doNotTreatPhpDocTypesAsCertain()) : $nodeScopeResolver->readTypeOfMaybeStored($dimFetch->var, $scope))->getOffsetValueType($nativeTypesPromoted ? $nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope->doNotTreatPhpDocTypesAsCertain()) : $nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope)), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + )]; + $scope = $result->getScope(); + + if ($enterExpressionAssign) { + $scope = $scope->exitExpressionAssign($dimExpr); + } + } + } + + if ($mode->issetSemanticsForRead()) { + // `$lvalue ??= ...` reads the chain with isset() semantics. The root + // and dimensions were just walked, so the read is composed from their + // stored results (processExprNodeConsumingStored) instead of + // re-walking the chain - it carries the isset descriptor (bug-13623) + // and stores read-flavoured results for the chain links, which is + // what parked rule asks observe; the write-flavoured results below + // then replace them, keeping the final storage as before. + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $originalVar); + $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $originalVar); + $targetReadResult = $nodeScopeResolver->processExprNodeConsumingStored($stmt, $originalVar, $readScope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $this->defaultNarrowingHelper->captureChainResults($originalVar, $storage, $targetChainResults); + } + foreach ($deferredDimFetchResults as [$deferredDimFetch, $deferredResult]) { + $nodeScopeResolver->storeExpressionResult($storage, $deferredDimFetch, $deferredResult); + } + + return new PreparedAssignTarget( + PreparedAssignTarget::KIND_ARRAY_DIM_FETCH, + $originalVar, + $assignedExpr, + $beforeScope, + $scope, + $enterExpressionAssign, + $isAssignOp, + $hasYield, + $throwPoints, + $impurePoints, + $isAlwaysTerminating, + rootVar: $var, + varResult: $varResult, + dimFetchStack: $dimFetchStack, + assignedPropertyExpr: $assignedPropertyExpr, + offsetTypes: $offsetTypes, + offsetNativeTypes: $offsetNativeTypes, + targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, + ); + } + + if ($var instanceof PropertyFetch) { + $objectResult = $nodeScopeResolver->processExprNode($stmt, $var->var, $scope, $storage, $nodeCallback, $context); + $hasYield = $objectResult->hasYield(); + $throwPoints = $objectResult->getThrowPoints(); + $impurePoints = $objectResult->getImpurePoints(); + $isAlwaysTerminating = $objectResult->isAlwaysTerminating(); + $scope = $objectResult->getScope(); + + $propertyName = null; + if ($var->name instanceof Node\Identifier) { + $propertyName = $var->name->name; + } else { + $propertyNameResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); + $hasYield = $hasYield || $propertyNameResult->hasYield(); + $throwPoints = array_merge($throwPoints, $propertyNameResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $propertyNameResult->getImpurePoints()); + $isAlwaysTerminating = $isAlwaysTerminating || $propertyNameResult->isAlwaysTerminating(); + $scope = $propertyNameResult->getScope(); + } + + $scopeBeforeAssignEval = $scope; + if ($mode->issetSemanticsForRead()) { + // `$lvalue ??= ...` reads the property with isset() semantics. The + // receiver and name were just walked, so the read is composed from + // their stored results instead of re-walking - it carries the isset + // descriptor (bug-13623). The consuming-stored call below then + // consumes and re-anchors this result to the pre-assign position, + // exactly as it consumed the separate pre-read's store before. + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); + $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); + $targetReadResult = $nodeScopeResolver->processExprNodeConsumingStored($stmt, $var, $readScope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $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. Price and store it once here, consuming the receiver's and + // name's stored results, so askers parked on it (DependencyResolver, + // property rules) resume with its pre-assign type. + $nodeScopeResolver->processExprNodeConsumingStored($stmt, $var, $scopeBeforeAssignEval, $storage, new NoopNodeCallback(), $context->enterDeep()); + + return new PreparedAssignTarget( + PreparedAssignTarget::KIND_PROPERTY_FETCH, + $var, + $assignedExpr, + $beforeScope, + $scope, + $enterExpressionAssign, + $isAssignOp, + $hasYield, + $throwPoints, + $impurePoints, + $isAlwaysTerminating, + objectResult: $objectResult, + propertyName: $propertyName, + targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, + ); + } + + if ($var instanceof Expr\StaticPropertyFetch) { + if ($var->class instanceof Node\Name) { + $propertyHolderType = $scope->resolveTypeByName($var->class); + } else { + $classResult = $nodeScopeResolver->processExprNode($stmt, $var->class, $scope, $storage, $nodeCallback, $context); + $propertyHolderType = $classResult->getType(); + } + + $propertyName = null; + if ($var->name instanceof Node\Identifier) { + $propertyName = $var->name->name; + } else { + $propertyNameResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); + $hasYield = $propertyNameResult->hasYield(); + $throwPoints = $propertyNameResult->getThrowPoints(); + $impurePoints = $propertyNameResult->getImpurePoints(); + $isAlwaysTerminating = $propertyNameResult->isAlwaysTerminating(); + $scope = $propertyNameResult->getScope(); + } + + $scopeBeforeAssignEval = $scope; + if ($mode->issetSemanticsForRead()) { + // Same as the PropertyFetch branch above: the ??= read is composed + // from the stored class/name results on the isset-semantics scope. + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); + $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); + $targetReadResult = $nodeScopeResolver->processExprNodeConsumingStored($stmt, $var, $readScope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $this->defaultNarrowingHelper->captureChainResults($var, $storage, $targetChainResults); + } + // Same as the PropertyFetch branch above: the emitted target fetch + // needs a stored result for parked askers. + $nodeScopeResolver->processExprNodeConsumingStored($stmt, $var, $scopeBeforeAssignEval, $storage, new NoopNodeCallback(), $context->enterDeep()); + + return new PreparedAssignTarget( + PreparedAssignTarget::KIND_STATIC_PROPERTY_FETCH, + $var, + $assignedExpr, + $beforeScope, + $scope, + $enterExpressionAssign, + $isAssignOp, + $hasYield, + $throwPoints, + $impurePoints, + $isAlwaysTerminating, + propertyName: $propertyName, + propertyHolderType: $propertyHolderType, + targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, + ); + } + + if ($var instanceof List_) { + return new PreparedAssignTarget( + PreparedAssignTarget::KIND_LIST, + $var, + $assignedExpr, + $beforeScope, + $scope, + $enterExpressionAssign, + $isAssignOp, + $hasYield, + $throwPoints, + $impurePoints, + $isAlwaysTerminating, + ); + } + + if ($var instanceof ExistingArrayDimFetch) { + $originalVar = $var; + $dimFetchStack = []; + $assignedPropertyExpr = $assignedExpr; + while ($var instanceof ExistingArrayDimFetch) { + $varForSetOffsetValue = $var->getVar(); + if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($nodeScopeResolver, $varForSetOffsetValue, $scope)); + } + $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( + $varForSetOffsetValue, + $var->getDim(), + $assignedPropertyExpr, + ); + $dimFetchStack[] = $var; + $var = $var->getVar(); + } - if ( - $expr instanceof AssignRef - && $expr->var instanceof Variable - && is_string($expr->var->name) - && $expr->expr instanceof Variable - && is_string($expr->expr->name) - ) { - $varName = $expr->var->name; - $refName = $expr->expr->name; - $type = $scope->getType($expr->var); - $nativeType = $scope->getNativeType($expr->var); + // the chain is usually a clone of AST nodes already processed elsewhere + // (see Unset_ handling) - process it with a noop callback so that + // results for its nodes are stored without invoking rules twice + $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); - // When $varName is assigned, update $refName - $scope = $scope->assignExpression( - new IntertwinedVariableByReferenceWithExpr($varName, new Variable($refName), new Variable($varName)), - $type, - $nativeType, - ); + $offsetTypes = []; + $offsetNativeTypes = []; + foreach (array_reverse($dimFetchStack) as $dimFetch) { + $dimExpr = $dimFetch->getDim(); + $dimResult = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $offsetTypes[] = [$dimResult->getType(), $dimFetch]; + $offsetNativeTypes[] = [$dimResult->getNativeType(), $dimFetch]; + } - // When $refName is assigned, update $varName - $scope = $scope->assignExpression( - new IntertwinedVariableByReferenceWithExpr($refName, new Variable($varName), new Variable($refName)), - $type, - $nativeType, + return new PreparedAssignTarget( + PreparedAssignTarget::KIND_EXISTING_ARRAY_DIM_FETCH, + $originalVar, + $assignedExpr, + $beforeScope, + $scope, + $enterExpressionAssign, + $isAssignOp, + $hasYield, + $throwPoints, + $impurePoints, + $isAlwaysTerminating, + rootVar: $var, + varResult: $varResult, + assignedPropertyExpr: $assignedPropertyExpr, + existingOffsetTypes: $offsetTypes, + existingOffsetNativeTypes: $offsetNativeTypes, ); } - $vars = $nodeScopeResolver->getAssignedVariables($expr->var); - if (count($vars) > 0) { - $varChangedScope = false; - $scope = $nodeScopeResolver->processVarAnnotation($scope, $vars, $stmt, $varChangedScope); - if (!$varChangedScope) { - $scope = $nodeScopeResolver->processStmtVarAnnotation($scope, $storage, $stmt, null, $nodeCallback); - } + $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->issetSemanticsForRead()) { + // a synthetic ??= target (e.g. InvalidBinaryOperationRule's + // TypeExpr-operand clone priced on demand): the walk above stored the + // target's result, the isset-semantics read consumes it + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); + $readScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); + $targetReadResult = $nodeScopeResolver->processExprNodeConsumingStored($stmt, $var, $readScope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $this->defaultNarrowingHelper->captureChainResults($var, $storage, $targetChainResults); } - return $this->expressionResultFactory->create( + return new PreparedAssignTarget( + PreparedAssignTarget::KIND_FALLBACK, + $var, + $assignedExpr, + $beforeScope, $scope, - beforeScope: $beforeScope, - expr: $expr, - hasYield: $result->hasYield(), - isAlwaysTerminating: $result->isAlwaysTerminating(), - throwPoints: $result->getThrowPoints(), - impurePoints: $result->getImpurePoints(), + $enterExpressionAssign, + $isAssignOp, + $hasYield, + $throwPoints, + $impurePoints, + $isAlwaysTerminating, + targetReadResult: $targetReadResult, + targetChainResults: $targetChainResults, ); } /** + * 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. $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 - * @param Closure(MutatingScope $scope): ExpressionResult $processExprCallback */ - public function processAssignVar( + public function applyWrite( NodeScopeResolver $nodeScopeResolver, - MutatingScope $scope, - ExpressionResultStorage $storage, + PreparedAssignTarget $target, + ExpressionResult $valueResult, + ?ExpressionResult $assignedValueResult, Node\Stmt $stmt, - Expr $var, - Expr $assignedExpr, + ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context, - Closure $processExprCallback, - bool $enterExpressionAssign, ): ExpressionResult { - $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 = []; - $impurePoints = []; - $isAlwaysTerminating = false; - $isAssignOp = $assignedExpr instanceof Expr\AssignOp && !$enterExpressionAssign; - if ($var instanceof Variable) { - $result = $processExprCallback($scope); + $kind = $target->getKind(); + $var = $target->getVar(); + $assignedExpr = $target->getAssignedExpr(); + $beforeScope = $target->getBeforeScope(); + $scope = $target->getScope(); + $enterExpressionAssign = $target->enterExpressionAssign(); + $isAssignOp = $target->isAssignOp(); + $hasYield = $target->hasYield(); + $throwPoints = $target->getThrowPoints(); + $impurePoints = $target->getImpurePoints(); + $isAlwaysTerminating = $target->isAlwaysTerminating(); + if ($kind === PreparedAssignTarget::KIND_VARIABLE) { + if (!$var instanceof Variable) { + throw new ShouldNotHappenException(); + } + $result = $valueResult; $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = $result->getImpurePoints(); @@ -441,40 +923,63 @@ public function processAssignVar( $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 + if ($assignedExpr !== $target->getAssignedExpr()) { + $assignedValueResult = $storage->findExpressionResult($assignedExpr); + } + $storedAssignedExprResult = $assignedValueResult ?? $storage->findExpressionResult($assignedExpr); + $type = $storedAssignedExprResult !== null + ? $storedAssignedExprResult->getTypeOnScope($scopeBeforeAssignEval, $scopeBeforeAssignEval->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($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); + $truthyType = $nodeScopeResolver->readTypeOfMaybeStored($if, $truthyScope); + $falseyType = $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr->else, $falsyScope); } - $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->filterBySpecifiedTypes($truthySpecifiedTypes); - $falsyScope = $condScope->filterBySpecifiedTypes($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, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $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 @@ -482,14 +987,18 @@ public function processAssignVar( // 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, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $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, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); } foreach ([null, false, 0, 0.0, '', '0', []] as $falseyScalar) { @@ -516,25 +1025,36 @@ public function processAssignVar( $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, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $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, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr, $storedAssignedExprResult); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $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, $var->name, $assignedExpr, new Variable($var->name)); } } else { $nameExprResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); @@ -544,120 +1064,46 @@ public function processAssignVar( $isAlwaysTerminating = $isAlwaysTerminating || $nameExprResult->isAlwaysTerminating(); $scope = $nameExprResult->getScope(); } - } elseif ($var instanceof ArrayDimFetch) { - $dimFetchStack = []; - $originalVar = $var; - $assignedPropertyExpr = $assignedExpr; - while ($var instanceof ArrayDimFetch) { - $varForSetOffsetValue = $var->var; - if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($varForSetOffsetValue, $scope)); - } - - if ( - $var === $originalVar - && $var->dim !== null - && $scope->hasExpressionType($var)->yes() - ) { - $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( - $varForSetOffsetValue, - $var->dim, - $assignedPropertyExpr, - ); - } else { - $assignedPropertyExpr = new SetOffsetValueTypeExpr( - $varForSetOffsetValue, - $var->dim, - $assignedPropertyExpr, - ); - } - $dimFetchStack[] = $var; - $var = $var->var; - } - - // 1. eval root expr - // The root is read to obtain the container that receives the offset write, so a - // property root must resolve to its readable type (not its writable one) even - // though it sits on the left-hand side of the assignment. - if ($enterExpressionAssign) { - $scope = $scope->enterExpressionAssign($var, false); - } - $result = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $hasYield = $result->hasYield(); - $throwPoints = $result->getThrowPoints(); - $impurePoints = $result->getImpurePoints(); - $isAlwaysTerminating = $result->isAlwaysTerminating(); - $scope = $result->getScope(); - if ($enterExpressionAssign) { - $scope = $scope->exitExpressionAssign($var); - } - - // 2. eval dimensions - $offsetTypes = []; - $offsetNativeTypes = []; - $dimFetchStack = array_reverse($dimFetchStack); - $lastDimKey = array_key_last($dimFetchStack); - foreach ($dimFetchStack as $key => $dimFetch) { - $dimExpr = $dimFetch->dim; - - // Callback was already called for last dim at the beginning of the method. - if ($key !== $lastDimKey) { - $nodeScopeResolver->callNodeCallback($nodeCallback, $dimFetch, $enterExpressionAssign ? $scope->enterExpressionAssign($dimFetch) : $scope, $storage); - } - - if ($dimExpr === null) { - $offsetTypes[] = [null, $dimFetch]; - $offsetNativeTypes[] = [null, $dimFetch]; - $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $dimFetch, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - )); - - } else { - if ($enterExpressionAssign) { - $scope->enterExpressionAssign($dimExpr); - } - $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( - $scope, - beforeScope: $scope, - expr: $dimFetch, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - )); - $result = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, $nodeCallback, $context->enterDeep()); - $offsetTypes[] = [$result->getType(), $dimFetch]; - $offsetNativeTypes[] = [$result->getNativeType(), $dimFetch]; - $hasYield = $hasYield || $result->hasYield(); - $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); - $scope = $result->getScope(); - - if ($enterExpressionAssign) { - $scope = $scope->exitExpressionAssign($dimExpr); - } - } + } elseif ($kind === PreparedAssignTarget::KIND_ARRAY_DIM_FETCH) { + if (!$var instanceof ArrayDimFetch) { + throw new ShouldNotHappenException(); } - - $valueToWrite = $scope->getType($assignedExpr); - $nativeValueToWrite = $scope->getNativeType($assignedExpr); + $originalVar = $var; + $var = $target->getRootVar(); + $varResult = $target->getVarResult(); + $dimFetchStack = $target->getDimFetchStack(); + $assignedPropertyExpr = $target->getAssignedPropertyExpr(); + $offsetTypes = $target->getOffsetTypes(); + $offsetNativeTypes = $target->getOffsetNativeTypes(); + // 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 = $processExprCallback($scope); + $result = $valueResult; $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); $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 = $storedValueResult !== null + ? $storedValueResult->getTypeOnScope($scopeBeforeAssignEval, $scopeBeforeAssignEval->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scopeBeforeAssignEval); + $nativeValueToWrite = $storedValueResult !== null + ? $storedValueResult->getTypeOnScope($nativeScopeBeforeAssignEval, $nativeScopeBeforeAssignEval->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $nativeScopeBeforeAssignEval); + + $varType = $varResult->getType(); + $varNativeType = $varResult->getNativeType(); // 4. compose types $isImplicitArrayCreation = $this->isImplicitArrayCreation($dimFetchStack, $scope); @@ -668,10 +1114,10 @@ public function processAssignVar( $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; - [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); + [$valueToWrite, $additionalExpressions] = $this->produceArrayDimFetchAssignValueToWrite($nodeScopeResolver, $dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope); 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); } else { $rewritten = false; foreach ($offsetTypes as $i => [$offsetType]) { @@ -690,7 +1136,7 @@ public function processAssignVar( continue; } - [$nativeValueToWrite] = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); + [$nativeValueToWrite] = $this->produceArrayDimFetchAssignValueToWrite($nodeScopeResolver, $dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope); $rewritten = true; break; } @@ -708,7 +1154,7 @@ public function processAssignVar( 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()); + $scope = $scope->assignInitializedProperty($nodeScopeResolver->readTypeOfMaybeStored($var->var, $scope), $var->name->toString()); } } $scope = $scope->assignExpression( @@ -723,7 +1169,7 @@ public function processAssignVar( } 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()); + $scope = $scope->assignInitializedProperty($nodeScopeResolver->readTypeOfMaybeStored($var->var, $scope), $var->name->toString()); } } } @@ -738,7 +1184,7 @@ public function processAssignVar( $scope = $scope->assignExpression($expr, $type, $nativeType); } - $setVarType = $scope->getType($originalVar->var); + $setVarType = $nodeScopeResolver->readTypeOfMaybeStored($originalVar->var, $scope); if ( !$setVarType instanceof ErrorType && !$setVarType->isArray()->yes() @@ -753,28 +1199,14 @@ public function processAssignVar( $context, )->getThrowPoints()); } - } elseif ($var instanceof PropertyFetch) { - $objectResult = $nodeScopeResolver->processExprNode($stmt, $var->var, $scope, $storage, $nodeCallback, $context); - $hasYield = $objectResult->hasYield(); - $throwPoints = $objectResult->getThrowPoints(); - $impurePoints = $objectResult->getImpurePoints(); - $isAlwaysTerminating = $objectResult->isAlwaysTerminating(); - $scope = $objectResult->getScope(); - - $propertyName = null; - if ($var->name instanceof Node\Identifier) { - $propertyName = $var->name->name; - } else { - $propertyNameResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); - $hasYield = $hasYield || $propertyNameResult->hasYield(); - $throwPoints = array_merge($throwPoints, $propertyNameResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $propertyNameResult->getImpurePoints()); - $isAlwaysTerminating = $isAlwaysTerminating || $propertyNameResult->isAlwaysTerminating(); - $scope = $propertyNameResult->getScope(); + } elseif ($kind === PreparedAssignTarget::KIND_PROPERTY_FETCH) { + if (!$var instanceof PropertyFetch) { + throw new ShouldNotHappenException(); } - + $objectResult = $target->getObjectResult(); + $propertyName = $target->getPropertyName(); $scopeBeforeAssignEval = $scope; - $result = $processExprCallback($scope); + $result = $valueResult; $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); @@ -785,10 +1217,10 @@ public function processAssignVar( $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()) { @@ -805,16 +1237,16 @@ public function processAssignVar( } 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(); @@ -849,9 +1281,9 @@ public function processAssignVar( } } 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())); // simulate dynamic property assign by __set to get throw points if (!$propertyHolderType->hasMethod('__set')->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( @@ -865,28 +1297,14 @@ public function processAssignVar( } } - } elseif ($var instanceof Expr\StaticPropertyFetch) { - if ($var->class instanceof Node\Name) { - $propertyHolderType = $scope->resolveTypeByName($var->class); - } else { - $nodeScopeResolver->processExprNode($stmt, $var->class, $scope, $storage, $nodeCallback, $context); - $propertyHolderType = $scope->getType($var->class); - } - - $propertyName = null; - if ($var->name instanceof Node\Identifier) { - $propertyName = $var->name->name; - } else { - $propertyNameResult = $nodeScopeResolver->processExprNode($stmt, $var->name, $scope, $storage, $nodeCallback, $context); - $hasYield = $propertyNameResult->hasYield(); - $throwPoints = $propertyNameResult->getThrowPoints(); - $impurePoints = $propertyNameResult->getImpurePoints(); - $isAlwaysTerminating = $propertyNameResult->isAlwaysTerminating(); - $scope = $propertyNameResult->getScope(); + } elseif ($kind === PreparedAssignTarget::KIND_STATIC_PROPERTY_FETCH) { + if (!$var instanceof Expr\StaticPropertyFetch) { + throw new ShouldNotHappenException(); } - + $propertyHolderType = $target->getPropertyHolderType(); + $propertyName = $target->getPropertyName(); $scopeBeforeAssignEval = $scope; - $result = $processExprCallback($scope); + $result = $valueResult; $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); @@ -895,7 +1313,7 @@ public function processAssignVar( 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()) { @@ -912,26 +1330,29 @@ public function processAssignVar( } 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 ($var instanceof List_) { - $result = $processExprCallback($scope); + } elseif ($kind === PreparedAssignTarget::KIND_LIST) { + if (!$var instanceof List_) { + throw new ShouldNotHappenException(); + } + $result = $valueResult; $hasYield = $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); @@ -962,8 +1383,12 @@ public function processAssignVar( } else { $dimExpr = $arrayItem->key; } - $getOffsetValueTypeExpr = new TypeExpr($scope->getType($assignedExpr)->getOffsetValueType($scope->getType($dimExpr))); - $result = $this->processAssignVar( + $getOffsetValueTypeExpr = new TypeExpr($this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope)->getOffsetValueType($nodeScopeResolver->readTypeOfMaybeStored($dimExpr, $scope))); + // 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, $storage, @@ -972,8 +1397,27 @@ public function processAssignVar( $getOffsetValueTypeExpr, $nodeCallback, $context, - fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $getOffsetValueTypeExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), - $enterExpressionAssign, + $enterExpressionAssign ? AssignTargetWalkMode::assign() : AssignTargetWalkMode::virtualAssign(), + ); + $result = $this->applyWrite( + $nodeScopeResolver, + $itemTarget, + $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, + $context, ); $scope = $result->getScope(); $hasYield = $hasYield || $result->hasYield(); @@ -981,41 +1425,16 @@ public function processAssignVar( $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); } - } elseif ($var instanceof ExistingArrayDimFetch) { - $dimFetchStack = []; - $assignedPropertyExpr = $assignedExpr; - while ($var instanceof ExistingArrayDimFetch) { - $varForSetOffsetValue = $var->getVar(); - if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($varForSetOffsetValue, $scope)); - } - $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( - $varForSetOffsetValue, - $var->getDim(), - $assignedPropertyExpr, - ); - $dimFetchStack[] = $var; - $var = $var->getVar(); - } - - // the chain is usually a clone of AST nodes already processed elsewhere - // (see Unset_ handling) - process it with a noop callback so that - // results for its nodes are stored without invoking rules twice - $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); - - $offsetTypes = []; - $offsetNativeTypes = []; - foreach (array_reverse($dimFetchStack) as $dimFetch) { - $dimExpr = $dimFetch->getDim(); - $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); - $offsetTypes[] = [$scope->getType($dimExpr), $dimFetch]; - $offsetNativeTypes[] = [$scope->getNativeType($dimExpr), $dimFetch]; - } - - $valueToWrite = $scope->getType($assignedExpr); - $nativeValueToWrite = $scope->getNativeType($assignedExpr); - $varType = $scope->getType($var); - $varNativeType = $scope->getNativeType($var); + } elseif ($kind === PreparedAssignTarget::KIND_EXISTING_ARRAY_DIM_FETCH) { + $var = $target->getRootVar(); + $varResult = $target->getVarResult(); + $assignedPropertyExpr = $target->getAssignedPropertyExpr(); + $offsetTypes = $target->getExistingOffsetTypes(); + $offsetNativeTypes = $target->getExistingOffsetNativeTypes(); + $valueToWrite = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope); + $nativeValueToWrite = $this->readAssignedValueType($nodeScopeResolver, $assignedValueResult, $assignedExpr, $scope->doNotTreatPhpDocTypesAsCertain()); + $varType = $varResult->getType(); + $varNativeType = $varResult->getNativeType(); $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; @@ -1055,13 +1474,7 @@ public function processAssignVar( ); } } else { - $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(); - $result = $processExprCallback($scope); + $result = $valueResult; $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); @@ -1069,8 +1482,18 @@ public function processAssignVar( $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( @@ -1094,6 +1517,16 @@ private function createArrayDimFetchConditionalExpressionHolder( ]); } + /** The assigned value's type at the given scope, read off the threaded result when available. */ + private function readAssignedValueType(NodeScopeResolver $nodeScopeResolver, ?ExpressionResult $assignedValueResult, Expr $assignedExpr, MutatingScope $scope): Type + { + if ($assignedValueResult !== null) { + return $assignedValueResult->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + return $nodeScopeResolver->readTypeOfMaybeStored($assignedExpr, $scope); + } + private function unwrapAssign(Expr $expr): Expr { if ($expr instanceof Assign) { @@ -1108,7 +1541,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, 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)) { @@ -1123,7 +1556,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $innerExpr, $this->exprPrinter->printExpr($innerExpr), - $scope->getType($innerExpr), + $this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $innerExpr, $assignedExpr, $assignedValueResult), TrinaryLogic::createMaybe(), ); continue; @@ -1137,7 +1570,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $expr, $exprString, - TypeCombinator::intersect($scope->getType($expr), $exprType), + TypeCombinator::intersect($this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $expr, $assignedExpr, $assignedValueResult), $exprType), TrinaryLogic::createYes(), ); } @@ -1150,7 +1583,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, 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)) { @@ -1179,7 +1612,7 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ $variableType, $expr, $exprString, - TypeCombinator::remove($scope->getType($expr), $exprType), + TypeCombinator::remove($this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $expr, $assignedExpr, $assignedValueResult), $exprType), TrinaryLogic::createYes(), ); } @@ -1187,6 +1620,33 @@ 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 readTypeOfMaybeStored(), + * whose stored ExpressionResult 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, 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); + } + + return $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); + } + /** * @param array $conditionalExpressions * @return array @@ -1245,13 +1705,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 []; } @@ -1379,12 +1843,12 @@ 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, 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(); + $keyType = $nodeScopeResolver->readTypeOfMaybeStored($arrayItem->key, $scope)->toArrayKey(); if ($implicitIndex !== null) { $keyValues = $keyType->getConstantScalarValues(); @@ -1410,7 +1874,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, $rootVarName, $arrayItem->value, $dimFetchExpr); } if (!$arrayItem->byRef || !$arrayItem->value instanceof Variable || !is_string($arrayItem->value->name)) { @@ -1419,8 +1883,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( @@ -1448,7 +1915,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): array { $originalValueToWrite = $valueToWrite; @@ -1467,14 +1934,14 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar $has = $offsetValueType->hasOffsetValueType($offsetType); if ($has->yes()) { if ($scope->hasExpressionType($dimFetch)->yes()) { - $offsetValueType = $scope->getType($dimFetch); + $offsetValueType = $nodeScopeResolver->readTypeOfMaybeStored($dimFetch, $scope); } else { $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); } } elseif ($has->maybe()) { if ($scope->hasExpressionType($dimFetch)->yes()) { $generalizeOnWrite = false; - $offsetValueType = $scope->getType($dimFetch); + $offsetValueType = $nodeScopeResolver->readTypeOfMaybeStored($dimFetch, $scope); } else { $offsetValueType = TypeCombinator::union($offsetValueType->getOffsetValueType($offsetType), new ConstantArrayType([], [])); } @@ -1552,7 +2019,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, $offsetValueType)) { $valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType()); } @@ -1575,7 +2042,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } elseif (isset($computedContainerValues[$key])) { $additionalValueType = $computedContainerValues[$key]; } else { - $offsetType = $scope->getType($dimFetch->dim); + $offsetType = $nodeScopeResolver->readTypeOfMaybeStored($dimFetch->dim, $scope); $additionalValueType = $valueToWrite->getOffsetValueType($offsetType); } @@ -1585,7 +2052,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, Type $offsetValueType): bool { if ($arrayDimFetch->dim instanceof Expr\BinaryOp\Plus) { if ( // keep list for $list[$index + 1] assignments @@ -1611,7 +2078,7 @@ 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() + && IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($nodeScopeResolver->readTypeOfMaybeStored($arrayDimFetch->dim, $scope))->yes() && $offsetValueType->isIterableAtLeastOnce()->yes() ) { return true; @@ -1649,12 +2116,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 fb25464e4b5..a4dee6b0953 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -5,27 +5,27 @@ use DivisionByZeroError; use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; -use PhpParser\Node\Expr\BinaryOp; -use PhpParser\Node\Expr\ConstFetch; -use PhpParser\Node\Name; use PhpParser\Node\Stmt; +use PHPStan\Analyser\AssignTargetWalkMode; 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\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\Type; use function array_merge; @@ -45,6 +45,8 @@ public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private CoalesceCompositionHelper $coalesceCompositionHelper, ) { } @@ -57,7 +59,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 { $beforeScope = $scope; - $assignResult = $this->assignHandler->processAssignVar( + + $target = $this->assignHandler->prepareTarget( $nodeScopeResolver, $scope, $storage, @@ -66,54 +69,174 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $context, - function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { - $originalScope = $scope; - if ($expr instanceof Expr\AssignOp\Coalesce) { - $scope = $scope->filterByFalseyValue( - new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), - ); - - if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $context = $context->enterRightSideAssign( - $expr->var->name, - $expr->expr, - ); - } - } - - $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - if ($expr instanceof Expr\AssignOp\Coalesce) { - $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $originalScope->getType($expr->var)->isNull()->yes(); - return $this->expressionResultFactory->create( - $exprResult->getScope()->mergeWith($originalScope), - $originalScope, - $expr->expr, - $exprResult->hasYield(), - $isAlwaysTerminating, - $exprResult->getThrowPoints(), - $exprResult->getImpurePoints(), - ); - } - - return $exprResult; - }, - $expr instanceof Expr\AssignOp\Coalesce, + $expr instanceof Expr\AssignOp\Coalesce ? AssignTargetWalkMode::coalesceReadModifyWrite() : AssignTargetWalkMode::readModifyWrite(), + ); + $condResult = $expr instanceof Expr\AssignOp\Coalesce ? $target->getTargetReadResult() : null; + $chainResults = $target->getTargetChainResults(); + + $rightResult = null; + $valueBeforeScope = $target->getScope(); + $valueScope = $valueBeforeScope; + $valueContext = $context; + if ($expr instanceof Expr\AssignOp\Coalesce) { + if ($condResult === null) { + throw new ShouldNotHappenException(); + } + + // the value expr only evaluates when the left side is null - the + // falsey narrowing of the coalesce, composed from the left read + $valueScope = $valueScope->applySpecifiedTypes($this->coalesceCompositionHelper->getFalseySpecifiedTypes($valueScope, $valueScope, $expr->var, $condResult, $expr, TypeSpecifierContext::createFalsey())); + + if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { + $valueContext = $valueContext->enterRightSideAssign( + $expr->var->name, + $expr->expr, + ); + } + } + + $valueResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $valueScope, $storage, $nodeCallback, $valueContext->enterDeep()); + if ($expr instanceof Expr\AssignOp\Coalesce) { + $rightResult = $valueResult; + $valueResult = $this->expressionResultFactory->create( + $rightResult->getScope()->mergeWith($valueBeforeScope), + $valueBeforeScope, + $expr->expr, + $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): Type { + // $expr->var and $expr->expr were processed during this handler's + // processExpr (the var as the assignment target, the value expr between + // prepareTarget() and applyWrite()), so their ExpressionResults are + // stored - read them instead of re-walking via Scope::getType(). + $getType = static fn (Expr $e): Type => $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope); + + 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 = fn (TypeSpecifierContext $context, bool $nativeTypesPromoted): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + $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, + $context, ); $scope = $assignResult->getScope(); $throwPoints = $assignResult->getThrowPoints(); $impurePoints = $assignResult->getImpurePoints(); if ( ($expr instanceof Expr\AssignOp\Div || $expr instanceof Expr\AssignOp\Mod) && - !$scope->getType($expr->expr)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + !$nodeScopeResolver->readStoredResult($expr->expr, $storage)->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($nodeScopeResolver, $expr->expr, $scope); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); } + if ($expr instanceof Expr\AssignOp\Coalesce) { + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $condResult, 'on left side of ??='), $beforeScope, $storage, $context); + } + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -122,71 +245,10 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto 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/BinaryOpHandler.php b/src/Analyser/ExprHandler/BinaryOpHandler.php index f8df18444ef..fbbce282813 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; @@ -66,8 +66,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 +94,601 @@ 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($nodeScopeResolver, $expr->left, $scope, $leftResult); + $rightToStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $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); + + $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); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $getType = static fn (Expr $expr): Type => $scope->getType($expr); + return $nodeScopeResolver->readTypeOfMaybeStored($e, $flavouredScope); + }; - if ($expr instanceof BinaryOp\Smaller) { - return $scope->getType($expr->left)->isSmallerThan($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\SmallerOrEqual) { - return $scope->getType($expr->left)->isSmallerThanOrEqual($scope->getType($expr->right), $this->phpVersion)->toBooleanType(); - } + if ($expr instanceof BinaryOp\SmallerOrEqual) { + return $getType($expr->left)->isSmallerThanOrEqual($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\Greater) { + return $getType($expr->right)->isSmallerThan($getType($expr->left), $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\GreaterOrEqual) { + return $getType($expr->right)->isSmallerThanOrEqual($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\Equal) { + return $this->resolveEqualType($scope, $expr, $leftResult, $rightResult); } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + 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); + } - return $this->initializerExprTypeResolver->resolveEqualType($leftType, $rightType)->type; - } + return new BooleanType(); + } - if ($expr instanceof BinaryOp\NotEqual) { - return $scope->getType(new Expr\BooleanNot(new BinaryOp\Equal($expr->left, $expr->right))); - } + if ($expr instanceof BinaryOp\Identical) { + return $this->richerScopeGetTypeHelper->getIdenticalResult($scope, $expr, $nodeScopeResolver, $getType($expr->left), $getType($expr->right))->type; + } - if ($expr instanceof BinaryOp\Identical) { - return $this->richerScopeGetTypeHelper->getIdenticalResult($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\NotIdentical) { - return $this->richerScopeGetTypeHelper->getNotIdenticalResult($scope, $expr)->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, $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, $nodeScopeResolver, $nativeTypesPromoted): Type { + if ($e === $expr->left) { + return $nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(); + } + if ($e === $expr->right) { + return $nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); } - } elseif ($context->truthy() && $leftType->getMin() !== null) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); + + return $nodeScopeResolver->readTypeOfMaybeStored($e, $scope); + }; + $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->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), - ); + if ($expr instanceof BinaryOp\GreaterOrEqual) { + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new BinaryOp\SmallerOrEqual($expr->right, $expr->left), $context)->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/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/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index cad60960e80..1d6c3d02ac1 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -4,34 +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 PHPStan\Type\TypeCombinator; use function array_merge; -use function array_reverse; -use function is_string; /** * @implements ExprHandler @@ -40,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, ) { @@ -55,211 +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 { - $leftNormalized = $this->conditionalExpressionHolderHelper->toSureTypes($leftTypes, $scope); - $rightNormalized = $this->conditionalExpressionHolderHelper->toSureTypes($rightTypes, $rightScope); - $types = $leftTypes->intersectWith($rightTypes); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scope, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, false, $types); - } - 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 — processBooleanConditionalTypes 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() === []) { - $truthyLeftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyLeftTypes)) { - $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); - } - } - if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === []) { - $truthyRightTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, TypeSpecifierContext::createTruthy()); - if ($this->allExpressionsTrackable($truthyRightTypes)) { - $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); - } - } - $result = (new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ))->withAlternativeTypesOf($types); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } - return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $rightCondTypes, $leftHolderTypes, false, true, $scope, $expr->left), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $rightCondTypes, $leftHolderTypes, true, true, $scope, $expr->left), - ]))->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 → union all SpecifiedTypes. - // Collect per-expression types first, then build unions once - // to avoid O(N²) from incremental growth. - /** @var array}> $sureTypesPerExpr */ - $sureTypesPerExpr = []; - /** @var array}> $sureNotTypesPerExpr */ - $sureNotTypesPerExpr = []; - - foreach ($arms as $arm) { - $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - foreach ($armTypes->getSureTypes() as $exprString => [$exprNode, $type]) { - $sureTypesPerExpr[$exprString][0] = $exprNode; - $sureTypesPerExpr[$exprString][1][] = $type; - } - foreach ($armTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { - $sureNotTypesPerExpr[$exprString][0] = $exprNode; - $sureNotTypesPerExpr[$exprString][1][] = $type; - } - } - - $sureTypes = []; - foreach ($sureTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; - } - $sureNotTypes = []; - foreach ($sureNotTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureNotTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; - } - - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->setRootExpr($expr); - } - - 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()); @@ -282,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 fb9914e56cf..9642863e987 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -11,27 +11,18 @@ 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 PHPStan\Type\TypeCombinator; -use function array_key_first; -use function array_key_last; use function array_merge; -use function array_reverse; -use function count; /** * @implements ExprHandler @@ -40,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, ) { @@ -55,213 +43,6 @@ 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. - * - * @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 = $this->conditionalExpressionHolderHelper->toSureTypes($rightTypes, $rightScope); - } elseif ( - $scope->getType($expr->left)->toBoolean()->isTrue()->yes() - || $scope->getType($expr->right)->toBoolean()->isFalse()->yes() - ) { - $types = $this->conditionalExpressionHolderHelper->toSureTypes($leftTypes, $scope); - } else { - $leftNormalized = $this->conditionalExpressionHolderHelper->toSureTypes($leftTypes, $scope); - $rightNormalized = $this->conditionalExpressionHolderHelper->toSureTypes($rightTypes, $rightScope); - $types = $leftTypes->intersectWith($rightTypes); - $types = $this->augmentBooleanOrTruthyWithConditionalHolders($typeSpecifier, $scope, $rightScope, $expr, $types); - $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($scope, $rightScope, $leftNormalized, $rightNormalized, $expr->left, $expr->right, true, $types); - } - } else { - $types = $leftTypes->unionWith($rightTypes); - } - - if ($context->true()) { - $result = (new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ))->withAlternativeTypesOf($types); - if ($types->shouldOverwrite()) { - $result = $result->setAlwaysOverwriteTypes(); - } - return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $leftTypes, $rightTypes, false, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $rightTypes, $leftTypes, false, false, $scope, $expr->left), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $leftTypes, $rightTypes, true, false, $rightScope, $expr->right), - $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($scope, $rightTypes, $leftTypes, true, false, $scope, $expr->left), - ]))->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 ||). - */ - 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 → union all SpecifiedTypes. - // Collect per-expression types first, then build unions once - // to avoid O(N²) from incremental TypeCombinator::union() growth. - /** @var array}> $sureTypesPerExpr */ - $sureTypesPerExpr = []; - /** @var array}> $sureNotTypesPerExpr */ - $sureNotTypesPerExpr = []; - - foreach ($arms as $arm) { - $armTypes = $typeSpecifier->specifyTypesInCondition($scope, $arm, $context); - foreach ($armTypes->getSureTypes() as $exprString => [$exprNode, $type]) { - $sureTypesPerExpr[$exprString][0] = $exprNode; - $sureTypesPerExpr[$exprString][1][] = $type; - } - foreach ($armTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { - $sureNotTypesPerExpr[$exprString][0] = $exprNode; - $sureNotTypesPerExpr[$exprString][1][] = $type; - } - } - - $sureTypes = []; - foreach ($sureTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::intersect(...$types)]; - } - $sureNotTypes = []; - foreach ($sureNotTypesPerExpr as $exprString => [$exprNode, $types]) { - $sureNotTypes[$exprString] = [$exprNode, TypeCombinator::union(...$types)]; - } - - return (new SpecifiedTypes($sureTypes, $sureNotTypes))->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); - } - /** * For `if ($a || $b)` truthy, expressions narrowed by stored conditional * holders (e.g. `$a = $obj instanceof ClassA;` records "when `$a` is @@ -280,69 +61,6 @@ private function specifyTypesForFlattenedBooleanOr( * skipped: in the OR-truthy scope the arm that didn't narrow could still be * the truthy one, so the sound result is the original (unnarrowed) type. */ - private function augmentBooleanOrTruthyWithConditionalHolders(TypeSpecifier $typeSpecifier, MutatingScope $scope, MutatingScope $rightScope, BooleanOr|LogicalOr $expr, SpecifiedTypes $types): SpecifiedTypes - { - $leftTruthyScope = null; - $rightTruthyScope = null; - - $seen = []; - foreach ([$scope, $rightScope] as $sourceScope) { - foreach ($sourceScope->getConditionalExpressions() as $exprString => $holders) { - if (isset($seen[$exprString])) { - continue; - } - if ($holders === []) { - continue; - } - $seen[$exprString] = true; - $targetExpr = $holders[array_key_first($holders)]->getTypeHolder()->getExpr(); - - // Only project when the target stays Yes-defined in the original - // scope and in both filtered branches. A sure type implicitly - // raises certainty to Yes, which would wrongly upgrade Maybe-defined - // variables — `if (empty($a['bar']))` for instance leaves `$a` - // Maybe-defined because `empty()` tolerates undefined offsets. - if (!$scope->hasExpressionType($targetExpr)->yes()) { - continue; - } - - $leftTruthyScope ??= $scope->filterByTruthyValue($expr->left); - if (!$leftTruthyScope->hasExpressionType($targetExpr)->yes()) { - continue; - } - $rightTruthyScope ??= $rightScope->filterByTruthyValue($expr->right); - if (!$rightTruthyScope->hasExpressionType($targetExpr)->yes()) { - continue; - } - - $origType = $scope->getType($targetExpr); - - $leftType = $leftTruthyScope->getType($targetExpr); - $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); - if (!$leftNarrowed) { - continue; - } - - $rightType = $rightTruthyScope->getType($targetExpr); - $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); - if (!$rightNarrowed) { - continue; - } - - $unionType = TypeCombinator::union($leftType, $rightType); - if ($unionType->equals($origType)) { - continue; - } - - $types = $types->unionWith( - $typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), - ); - } - } - - return $types; - } - 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()); @@ -365,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/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/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index bb13a0e2817..b6c6a65033a 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($nodeScopeResolver, $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/ClosureHandler.php b/src/Analyser/ExprHandler/ClosureHandler.php index b107c5843c6..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, ) { } @@ -43,6 +41,31 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); + // 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()), beforeScope: $scope, @@ -51,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/CoalesceHandler.php b/src/Analyser/ExprHandler/CoalesceHandler.php index d7b0fa53571..7e56fd3e7d9 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -10,20 +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\ShouldNotHappenException; +use PHPStan\Node\CoalesceExpressionNode; 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; /** @@ -36,6 +35,8 @@ final class CoalesceHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private CoalesceCompositionHelper $coalesceCompositionHelper, ) { } @@ -45,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; @@ -126,15 +55,32 @@ 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, $nodeScopeResolver), + $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); + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -143,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/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/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 54e9c397fa1..1e94f7470b5 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,15 +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\ShouldNotHappenException; +use PHPStan\Node\EmptyExpressionNode; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Type; @@ -34,6 +33,8 @@ final class EmptyHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -43,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; @@ -95,6 +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, @@ -103,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 ($nodeScopeResolver, $chainResults, $expr, $exprResult, $issetNode, $notIssetNode): SpecifiedTypes { + if ($ctx->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($notIssetNode, $ctx); + } + $negated = $ctx->negate(); + $readType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $scope, $nodeScopeResolver); + 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/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/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/FuncCallHandler.php b/src/Analyser/ExprHandler/FuncCallHandler.php index 3b5d306ffc5..6214ee3bddc 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -13,15 +13,18 @@ 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; use PHPStan\Analyser\MutatingScope; @@ -96,7 +99,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, @@ -106,6 +108,10 @@ public function __construct( #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, + private DynamicReturnTypeStoragePrimer $storagePrimer, ) { } @@ -119,22 +125,29 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $beforeScope = $scope; $parametersAcceptor = null; + $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()) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $nameType->getCallableParametersAcceptors($scope), - null, - ); + $variants = $nameType->getCallableParametersAcceptors($scope); + // A structural acceptor (names/positions/variadic) drives the per-arg + // metadata and the throw/impure points - generics are resolved + // type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, null); } $scope = $nameResult->getScope(); @@ -159,16 +172,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } elseif ($this->reflectionProvider->hasFunction($expr->name, $scope)) { $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $functionReflection->getVariants(), - $functionReflection->getNamedArgumentsVariants(), - ); - $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor, $scope, $expr->getArgs()); - if ($impurePoint !== null) { - $impurePoints[] = new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); - } + $variants = $functionReflection->getVariants(); + $namedArgumentsVariants = $functionReflection->getNamedArgumentsVariants(); + // A structural acceptor (names/positions/variadic) drives argument + // normalization, the impure point and the throw points - generics are + // resolved type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); } else { $impurePoints[] = new ImpurePoint( $scope, @@ -192,7 +201,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) { @@ -250,51 +265,66 @@ 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 = static function (Node $node, Scope $scope) use ($nodeCallback, $callbackArg, $firstParamName, &$arrayWalkValueTypes): void { - if ($node instanceof ClosureReturnStatementsNode && $node->getClosureExpr() === $callbackArg) { - $types = []; - $nativeTypes = []; - $stmtResult = $node->getStatementResult(); - foreach ($stmtResult->getExitPoints() as $exitPoint) { - $exitScope = $exitPoint->getScope(); - if (!$exitScope->hasVariableType($firstParamName)->yes()) { - continue; - } - $types[] = $exitScope->getVariableType($firstParamName); - $nativeTypes[] = $exitScope->getNativeType(new Variable($firstParamName)); - } - if (!$stmtResult->isAlwaysTerminating()) { - $stmtScope = $stmtResult->getScope(); - if ($stmtScope->hasVariableType($firstParamName)->yes()) { - $types[] = $stmtScope->getVariableType($firstParamName); - $nativeTypes[] = $stmtScope->getNativeType(new Variable($firstParamName)); - } + $nodeCallbackForArgs = new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($callbackArg, $firstParamName, &$arrayWalkValueTypes): void { + if (!($node instanceof ClosureReturnStatementsNode) || $node->getClosureExpr() !== $callbackArg) { + return; + } + + $types = []; + $nativeTypes = []; + $stmtResult = $node->getStatementResult(); + foreach ($stmtResult->getExitPoints() as $exitPoint) { + $exitScope = $exitPoint->getScope(); + if (!$exitScope->hasVariableType($firstParamName)->yes()) { + continue; } - if (count($types) > 0) { - $arrayWalkValueTypes = [ - TypeCombinator::union(...$types), - TypeCombinator::union(...$nativeTypes), - ]; + + $types[] = $exitScope->getVariableType($firstParamName); + $nativeTypes[] = $exitScope->getNativeType(new Variable($firstParamName)); + } + if (!$stmtResult->isAlwaysTerminating()) { + $stmtScope = $stmtResult->getScope(); + if ($stmtScope->hasVariableType($firstParamName)->yes()) { + $types[] = $stmtScope->getVariableType($firstParamName); + $nativeTypes[] = $stmtScope->getNativeType(new Variable($firstParamName)); } } - $nodeCallback($node, $scope); - }; + if (count($types) <= 0) { + return; + } + + $arrayWalkValueTypes = [ + TypeCombinator::union(...$types), + TypeCombinator::union(...$nativeTypes), + ]; + }, $nodeCallback); } } $scopeBeforeArgs = $scope; - $argsResult = $nodeScopeResolver->processArgs($stmt, $functionReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallbackForArgs, $context); + $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()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); + 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 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()); + } + } + 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); @@ -310,6 +340,94 @@ 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. + $typeCallback = $isEarlyTerminating + ? static fn (bool $nativeTypesPromoted): Type => new NeverType(true) + : fn (bool $nativeTypesPromoted): Type => $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): 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; + $argType = $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 dynamic return type extensions, one of which + // (TypeSpecifyingFunctionsDynamicReturnTypeExtension via + // ImpossibleCheckTypeHelper) narrows this very call. Without a stored result + // that narrowing 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 ( @@ -332,7 +450,28 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } if ($functionReflection !== null) { - $functionThrowPoint = $this->getFunctionThrowPoint($functionReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + // 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, $returnType, $normalizedExpr, $scope, $context); if ($functionThrowPoint !== null) { $throwPoints[] = $functionThrowPoint; } @@ -416,8 +555,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(); @@ -452,13 +591,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(); } @@ -598,20 +742,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, @@ -632,7 +769,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); } @@ -660,8 +796,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); } } @@ -670,19 +805,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) { @@ -813,27 +952,54 @@ 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. + return $nodeScopeResolver->readTypeOfMaybeStored($e, $nativeTypesPromoted ? $reflectionScope->doNotTreatPhpDocTypesAsCertain() : $reflectionScope); + }; 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_) { @@ -849,9 +1015,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; } @@ -860,45 +1026,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) { @@ -919,31 +1087,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; } @@ -951,56 +1125,62 @@ 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); + $calleeType = $nameResult !== null + ? $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($call->name, $scope); $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(); } @@ -1017,21 +1197,90 @@ 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(); + } + + $nameType = $nameResult !== null + ? $nameResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $scope); + 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/BooleanNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php new file mode 100644 index 00000000000..be3f9ce51c6 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/BooleanNarrowingHelper.php @@ -0,0 +1,242 @@ +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() === []) { + $truthyLeftTypes = $leftTypesCallback($s, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } + } + if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === []) { + $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; + } + + 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/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index ff165386f75..606fe9c1f72 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -8,11 +8,14 @@ use PhpParser\Node\Expr\Variable; use PhpParser\Node\Expr\Yield_; use PhpParser\Node\Expr\YieldFrom; +use PhpParser\NodeFinder; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ImpurePoint; +use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\PerFileAnalysisResettable; use PHPStan\Analyser\Scope; use PHPStan\Analyser\StatementContext; use PHPStan\Analyser\ThrowPoint; @@ -25,7 +28,10 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ExtendedParameterReflection; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\Native\NativeParameterReflection; +use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\Php\DummyParameter; use PHPStan\ShouldNotHappenException; @@ -38,153 +44,171 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\NullType; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\VerbosityLevel; use PHPStan\Type\VoidType; +use WeakMap; use function array_key_exists; +use function array_keys; use function array_map; use function array_merge; use function count; +use function implode; +use function in_array; use function is_string; #[AutowiredService] -final class ClosureTypeResolver +final class ClosureTypeResolver implements PerFileAnalysisResettable { private static int $resolveClosureTypeDepth = 0; + /** + * Per-context resolved closure types, keyed by the closure node. Node + * attributes would persist on the parser cache's retained ASTs after the + * file's analysis ends - a WeakMap reset per file releases the Types and + * throw/impure points with the rest of the file's result graph. + * + * @var WeakMap> + */ + private WeakMap $cachedTypes; + public function __construct( private NodeScopeResolver $nodeScopeResolver, + private InitializerExprTypeResolver $initializerExprTypeResolver, ) { + $this->cachedTypes = new WeakMap(); + } + + public function resetFileAnalysisState(): void + { + $this->cachedTypes = new WeakMap(); } + /** + * 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 + * 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 (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, Node\Expr\Closure|ArrowFunction $expr, + bool $shallow = false, ): ClosureType { - $parameters = []; - $isVariadic = false; - $firstOptionalParameterIndex = null; - foreach ($expr->params as $i => $param) { - $isOptionalCandidate = $param->default !== null || $param->variadic; - - if ($isOptionalCandidate) { - if ($firstOptionalParameterIndex === null) { - $firstOptionalParameterIndex = $i; - } - } else { - $firstOptionalParameterIndex = null; - } - } - - foreach ($expr->params as $i => $param) { - if ($param->variadic) { - $isVariadic = true; - } - if (!$param->var instanceof Variable || !is_string($param->var->name)) { - throw new ShouldNotHappenException(); - } - $parameters[] = new NativeParameterReflection( - $param->var->name, - $firstOptionalParameterIndex !== null && $i >= $firstOptionalParameterIndex, - $scope->getFunctionType($param->type, $scope->isParameterValueNullable($param), false), - $param->byRef - ? PassedByReference::createCreatesNewVariable() - : PassedByReference::createNo(), - $param->variadic, - $param->default !== null ? $scope->getType($param->default) : null, + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); + + // A shallow reflection is the closure/arrow function's signature without + // walking its body: parameters plus the DECLARED return type. Used at scope + // 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::processClosureNodeInternal() + // and processArrowFunctionNode()). + if ($shallow) { + return new ClosureType( + $parameters, + $scope->getFunctionType($expr->returnType, false, false), + $isVariadic, + isStatic: TrinaryLogic::createFromBoolean($expr->static), ); } - $callableParameters = null; - $nativeCallableParameters = null; - $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); - $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); - if ($arrayMapArgs !== null) { - $callableParameters = []; - $nativeCallableParameters = []; - foreach ($arrayMapArgs as $funcCallArg) { - $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - } - } elseif ($immediatelyInvokedArgs !== null) { - foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { - $callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - } - } else { - $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); - if ($inFunctionCallsStackCount > 0) { - [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; - if ($inParameter !== null) { - $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); - $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); - $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); - } - } + $cachedTypes = $this->cachedTypes[$expr] ?? []; + $cacheKey = $this->closureContextCacheKey($scope, $expr, $callableParameters, $parameters); + if (array_key_exists($cacheKey, $cachedTypes)) { + return $this->createClosureTypeFromCache($expr, $parameters, $isVariadic, $cachedTypes[$cacheKey]); + } + if (self::$resolveClosureTypeDepth >= 2) { + return new ClosureType( + $parameters, + $scope->getFunctionType($expr->returnType, false, false), + $isVariadic, + isStatic: TrinaryLogic::createFromBoolean($expr->static), + ); } if ($expr instanceof ArrowFunction) { $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { - $yieldNode = $expr->expr; + $arrowFunctionImpurePoints = []; + $invalidateExpressions = []; + self::$resolveClosureTypeDepth++; + try { + $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( + new Node\Stmt\Expression($expr->expr), + $expr->expr, + $arrowScope, + new ExpressionResultStorage(), + static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { + if ($scope->getAnonymousFunctionReflection() !== $arrowScope->getAnonymousFunctionReflection()) { + return; + } - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyType = new IntegerType(); - } else { - $keyType = $arrowScope->getType($yieldNode->key); - } + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } - if ($yieldNode->value === null) { - $valueType = new NullType(); - } else { - $valueType = $arrowScope->getType($yieldNode->value); - } - } else { - $yieldFromType = $arrowScope->getType($yieldNode->expr); - $keyType = $arrowScope->getIterableKeyType($yieldFromType); - $valueType = $arrowScope->getIterableValueType($yieldFromType); - } + if (!$node instanceof PropertyAssignNode) { + return; + } - $returnType = new GenericObjectType(Generator::class, [ - $keyType, - $valueType, - new MixedType(), - new VoidType(), - ]); - } else { - $returnType = $arrowScope->getKeepVoidType($expr->expr); - if ($expr->returnType !== null) { - $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); - $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); - } + $arrowFunctionImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + }, + ExpressionContext::createDeep(), + ); + } finally { + self::$resolveClosureTypeDepth--; } + $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); + $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); - $arrowFunctionImpurePoints = []; - $invalidateExpressions = []; - $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( - new Node\Stmt\Expression($expr->expr), - $expr->expr, - $arrowScope, - new ExpressionResultStorage(), - static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { - if ($scope->getAnonymousFunctionReflection() !== $arrowScope->getAnonymousFunctionReflection()) { - return; - } + // the body was processed just above; resolve the return type from its stored + // result rather than reading the still-unprocessed body expression + $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr); - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $cacheKey); + } - if (!$node instanceof PropertyAssignNode) { - return; - } + self::$resolveClosureTypeDepth++; - $arrowFunctionImpurePoints[] = new ImpurePoint( + $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); + $closureReturnStatements = []; + $closureYieldStatements = []; + $closureExecutionEnds = []; + $closureImpurePoints = []; + $invalidateExpressions = []; + + try { + $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($expr, $expr->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$closureExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { + if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { + return; + } + + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } + + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( $scope, $node, 'propertyAssign', @@ -192,219 +216,607 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu true, ); $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - }, - ExpressionContext::createDeep(), - ); - $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); - $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); - $usedVariables = []; + return; + } + + if ($node instanceof ExecutionEndNode) { + $closureExecutionEnds[] = $node; + return; + } + + if ($node instanceof Node\Stmt\Return_) { + $closureReturnStatements[] = [$node, $scope]; + } + + if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { + return; + } + + $closureYieldStatements[] = [$node, $scope]; + }, StatementContext::createTopLevel()); + } finally { + self::$resolveClosureTypeDepth--; + } + + $throwPoints = $closureStatementResult->getThrowPoints(); + $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); + + return $this->buildClosureTypeFromClosureWalk( + $scope, + $expr, + $parameters, + $isVariadic, + $closureReturnStatements, + $closureYieldStatements, + $closureExecutionEnds, + $throwPoints, + $impurePoints, + $invalidateExpressions, + $cacheKey, + ); + } + + /** + * Constructs a closure type from data the engine already gathered while + * walking the body once (see NodeScopeResolver::processClosureNode()), + * without a second walk. The return/yield expression types are read from + * their stored results. + * + * @param list $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param InternalThrowPoint[] $throwPoints the single body walk's internal throw points + * @param ImpurePoint[] $impurePoints already merged (property-assign impure points + statement result impure points) + * @param InvalidateExprNode[] $invalidateExpressions + */ + public function buildClosureTypeForClosure( + MutatingScope $scope, + Node\Expr\Closure $expr, + array $returnStatements, + array $yieldStatements, + array $executionEnds, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + bool $native = false, + ): ClosureType + { + if ($this->bodyWalkHasOwnParameterTypes($expr)) { + return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr); + } + + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); + + return $this->buildClosureTypeFromClosureWalk( + $scope, + $expr, + $parameters, + $isVariadic, + $returnStatements, + $yieldStatements, + $executionEnds, + array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $throwPoints), + $impurePoints, + $invalidateExpressions, + // the flavour-correct key both keeps the native build from clobbering + // the phpdoc cache slot and lets a later getClosureType() ask on the + // promoted scope answer from this build instead of re-walking + $this->closureContextCacheKey( + $native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, + $expr, + $native ? $nativeCallableParameters : $callableParameters, + $parameters, + ), + $native, + ); + } + + /** + * Constructs an arrow function type from data the engine already gathered + * while walking the body once (see NodeScopeResolver:: + * processArrowFunctionNode()), without a second walk. The return/yield + * expression types are read from their stored results on $arrowScope. + * + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints already merged (property-assign impure points + expression result impure points) + * @param InvalidateExprNode[] $invalidateExpressions + */ + public function buildClosureTypeForArrowFunction( + MutatingScope $scope, + ArrowFunction $expr, + MutatingScope $arrowScope, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + bool $native = false, + ): ClosureType + { + if ($this->bodyWalkHasOwnParameterTypes($expr)) { + return $this->getClosureType($native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, $expr); + } + + [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters] = $this->buildParametersAndAcceptors($scope, $expr); + + $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native); + + // the flavour-correct key both keeps the native build from clobbering the + // phpdoc cache slot and lets a later getClosureType() ask on the promoted + // scope answer from this build instead of re-walking + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, [], $this->closureContextCacheKey( + $native ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope, + $expr, + $native ? $nativeCallableParameters : $callableParameters, + $parameters, + )); + } + + /** + * 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. + */ + /** + * 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 + */ + /** + * The cache key of everything this closure's type can depend on: the + * 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 + */ + private function closureContextCacheKey(MutatingScope $scope, Node\Expr\Closure|ArrowFunction $expr, ?array $callableParameters, array $parameters): string + { + $parts = []; + foreach ($callableParameters ?? $parameters as $parameter) { + $parts[] = $parameter->getType()->describe(VerbosityLevel::cache()); + } + + 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 - null when the body accesses variables dynamically and the + * whole scope must key the cache. + * + * @return list|null + */ + private function freeVariableRoots(Node\Expr\Closure|ArrowFunction $expr): ?array + { + /** @var list|false|null $cached */ + $cached = $expr->getAttribute('phpstanFreeVariableRoots', false); + if ($cached !== false) { + return $cached; + } + + $roots = []; + if (!$expr->static) { + $roots['$this'] = true; + } + + if ($expr instanceof Node\Expr\Closure) { + foreach ($expr->uses as $use) { + if (!is_string($use->var->name)) { + $expr->setAttribute('phpstanFreeVariableRoots', null); + return null; + } + $roots['$' . $use->var->name] = true; + } } else { - $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cacheKey = $scope->getClosureScopeCacheKey(); - if (array_key_exists($cacheKey, $cachedTypes)) { - $cachedClosureData = $cachedTypes[$cacheKey]; - - $mustUseReturnValue = TrinaryLogic::createNo(); - foreach ($expr->attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attr) { - if ($attr->name->toLowerString() === 'nodiscard') { - $mustUseReturnValue = TrinaryLogic::createYes(); - break; - } - } + $paramNames = []; + foreach ($expr->params as $param) { + if (!($param->var instanceof Node\Expr\Variable) || !is_string($param->var->name)) { + continue; } - return new ClosureType( - $parameters, - $cachedClosureData['returnType'], - $isVariadic, - TemplateTypeMap::createEmpty(), - TemplateTypeMap::createEmpty(), - TemplateTypeVarianceMap::createEmpty(), - throwPoints: $cachedClosureData['throwPoints'], - impurePoints: $cachedClosureData['impurePoints'], - invalidateExpressions: $cachedClosureData['invalidateExpressions'], - usedVariables: $cachedClosureData['usedVariables'], - acceptsNamedArguments: TrinaryLogic::createYes(), - mustUseReturnValue: $mustUseReturnValue, - isStatic: TrinaryLogic::createFromBoolean($expr->static), - ); + $paramNames['$' . $param->var->name] = true; } - if (self::$resolveClosureTypeDepth >= 2) { - return new ClosureType( - $parameters, - $scope->getFunctionType($expr->returnType, false, false), - $isVariadic, - isStatic: TrinaryLogic::createFromBoolean($expr->static), - ); + $finder = new NodeFinder(); + foreach ($finder->findInstanceOf([$expr->expr], Node\Expr\Variable::class) as $variable) { + if (!is_string($variable->name)) { + $expr->setAttribute('phpstanFreeVariableRoots', null); + return null; + } + $name = '$' . $variable->name; + if (isset($paramNames[$name])) { + continue; + } + $roots[$name] = true; + } + foreach ($finder->findInstanceOf([$expr->expr], Node\Expr\FuncCall::class) as $call) { + if ($call->name instanceof Node\Name && in_array($call->name->toLowerString(), ['compact', 'extract', 'get_defined_vars'], true)) { + $expr->setAttribute('phpstanFreeVariableRoots', null); + return null; + } } + } - self::$resolveClosureTypeDepth++; + $rootList = array_keys($roots); + $expr->setAttribute('phpstanFreeVariableRoots', $rootList); - $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - $closureReturnStatements = []; - $closureYieldStatements = []; - $onlyNeverExecutionEnds = null; - $closureImpurePoints = []; - $invalidateExpressions = []; + return $rootList; + } - try { - $closureStatementResult = $this->nodeScopeResolver->processStmtNodes($expr, $expr->stmts, $closureScope, static function (Node $node, Scope $scope) use ($closureScope, &$closureReturnStatements, &$closureYieldStatements, &$onlyNeverExecutionEnds, &$closureImpurePoints, &$invalidateExpressions): void { - if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { - return; - } + private function bodyWalkHasOwnParameterTypes(Node\Expr\Closure|ArrowFunction $expr): bool + { + return $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null + || $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME) !== null; + } - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } + /** + * @param list $parameters + * @param list $returnStatements + * @param list $yieldStatements + * @param list $executionEnds + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + */ + private function buildClosureTypeFromClosureWalk( + MutatingScope $scope, + Node\Expr\Closure $expr, + array $parameters, + bool $isVariadic, + array $returnStatements, + array $yieldStatements, + array $executionEnds, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + ?string $cacheKey = null, + bool $native = false, + ): ClosureType + { + $onlyNeverExecutionEnds = $this->deriveOnlyNeverExecutionEnds($executionEnds); + + // like resolveArrowFunctionReturnType(): the single walk stored both + // flavours on the gathered scopes, so the native flavour just reads the + // stored native types - no second body walk on the promoted scope + $returnTypes = []; + $hasNull = false; + foreach ($returnStatements as [$returnNode, $returnScope]) { + if ($returnNode->expr === null) { + $hasNull = true; + continue; + } - if ($node instanceof PropertyAssignNode) { - $closureImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - return; - } + $readScope = $returnScope->toMutatingScope(); + if ($native) { + $readScope = $readScope->doNotTreatPhpDocTypesAsCertain(); + } + $returnTypes[] = $readScope->getType($returnNode->expr); + } - if ($node instanceof ExecutionEndNode) { - if ($node->getStatementResult()->isAlwaysTerminating()) { - foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { - if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { - $onlyNeverExecutionEnds = false; - continue; - } - - if ($onlyNeverExecutionEnds === null) { - $onlyNeverExecutionEnds = true; - } - - break; - } - - if (count($node->getStatementResult()->getExitPoints()) === 0) { - if ($onlyNeverExecutionEnds === null) { - $onlyNeverExecutionEnds = true; - } - } - } else { - $onlyNeverExecutionEnds = false; - } + if (count($returnTypes) === 0) { + if ($onlyNeverExecutionEnds === true && !$hasNull) { + $returnType = new NonAcceptingNeverType(); + } else { + $returnType = new VoidType(); + } + } else { + if ($onlyNeverExecutionEnds === true) { + $returnTypes[] = new NonAcceptingNeverType(); + } + if ($hasNull) { + $returnTypes[] = new NullType(); + } + $returnType = TypeCombinator::union(...$returnTypes); + } - return; + if (count($yieldStatements) > 0) { + $keyTypes = []; + $valueTypes = []; + foreach ($yieldStatements as [$yieldNode, $yieldScope]) { + $readScope = $yieldScope->toMutatingScope(); + if ($native) { + $readScope = $readScope->doNotTreatPhpDocTypesAsCertain(); + } + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyTypes[] = new IntegerType(); + } else { + $keyTypes[] = $readScope->getType($yieldNode->key); } - if ($node instanceof Node\Stmt\Return_) { - $closureReturnStatements[] = [$node, $scope]; + if ($yieldNode->value === null) { + $valueTypes[] = new NullType(); + } else { + $valueTypes[] = $readScope->getType($yieldNode->value); } - if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { - return; - } + continue; + } - $closureYieldStatements[] = [$node, $scope]; - }, StatementContext::createTopLevel()); - } finally { - self::$resolveClosureTypeDepth--; + $yieldFromType = $readScope->getType($yieldNode->expr); + $keyTypes[] = $readScope->getIterableKeyType($yieldFromType); + $valueTypes[] = $readScope->getIterableValueType($yieldFromType); } - $throwPoints = $closureStatementResult->getThrowPoints(); - $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); + $returnType = new GenericObjectType(Generator::class, [ + TypeCombinator::union(...$keyTypes), + TypeCombinator::union(...$valueTypes), + new MixedType(), + $returnType, + ]); + } else { + if ($expr->returnType !== null) { + $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); + $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + } + } - $returnTypes = []; - $hasNull = false; - foreach ($closureReturnStatements as [$returnNode, $returnScope]) { - if ($returnNode->expr === null) { - $hasNull = true; - continue; - } + $usedVariables = []; + foreach ($expr->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + + $usedVariables[] = $use->var->name; + } - $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); + foreach ($expr->uses as $use) { + if (!$use->byRef) { + continue; } - if (count($returnTypes) === 0) { - if ($onlyNeverExecutionEnds === true && !$hasNull) { - $returnType = new NonAcceptingNeverType(); + $impurePoints[] = new ImpurePoint( + $scope, + $expr, + 'functionCall', + 'call to a Closure with by-ref use', + true, + ); + break; + } + + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, $usedVariables, $cacheKey); + } + + private function resolveArrowFunctionReturnType( + MutatingScope $scope, + MutatingScope $arrowScope, + ArrowFunction $expr, + bool $native = false, + ): Type + { + // Unlike a closure (whose native type equals its phpdoc type), an arrow + // function's native return type is the body expression's native type + // (e.g. fn () => methodReturningPositiveInt() is Closure(): int natively, + // Closure(): int<1, max> in phpdoc). The body was already processed in the + // single walk, so the native flavour just reads the stored native types off + // the same arrowScope - no second walk. + $readScope = $native ? $arrowScope->doNotTreatPhpDocTypesAsCertain() : $arrowScope; + + if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { + $yieldNode = $expr->expr; + + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyType = new IntegerType(); } else { - $returnType = new VoidType(); + $keyType = $readScope->getType($yieldNode->key); } - } else { - if ($onlyNeverExecutionEnds === true) { - $returnTypes[] = new NonAcceptingNeverType(); - } - if ($hasNull) { - $returnTypes[] = new NullType(); + + if ($yieldNode->value === null) { + $valueType = new NullType(); + } else { + $valueType = $readScope->getType($yieldNode->value); } - $returnType = TypeCombinator::union(...$returnTypes); + } else { + $yieldFromType = $readScope->getType($yieldNode->expr); + $keyType = $readScope->getIterableKeyType($yieldFromType); + $valueType = $readScope->getIterableValueType($yieldFromType); } - if (count($closureYieldStatements) > 0) { - $keyTypes = []; - $valueTypes = []; - foreach ($closureYieldStatements as [$yieldNode, $yieldScope]) { - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyTypes[] = new IntegerType(); - } else { - $keyTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->key); - } + return new GenericObjectType(Generator::class, [ + $keyType, + $valueType, + new MixedType(), + new VoidType(), + ]); + } - if ($yieldNode->value === null) { - $valueTypes[] = new NullType(); - } else { - $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); - } + $returnType = $readScope->getKeepVoidType($expr->expr); + if ($expr->returnType !== null) { + $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); + $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + } + return $returnType; + } + + /** + * Whether every execution end of the closure body is a "never" terminator + * (throw/exit) rather than a return: null when there were no execution ends, + * false once a return (or non-terminating end) is seen. + * + * @param list $executionEnds + */ + private function deriveOnlyNeverExecutionEnds(array $executionEnds): ?bool + { + $onlyNeverExecutionEnds = null; + foreach ($executionEnds as $node) { + if ($node->getStatementResult()->isAlwaysTerminating()) { + foreach ($node->getStatementResult()->getExitPoints() as $exitPoint) { + if ($exitPoint->getStatement() instanceof Node\Stmt\Return_) { + $onlyNeverExecutionEnds = false; continue; } - $yieldFromType = $yieldScope->toMutatingScope()->getType($yieldNode->expr); - $keyTypes[] = $yieldScope->toMutatingScope()->getIterableKeyType($yieldFromType); - $valueTypes[] = $yieldScope->toMutatingScope()->getIterableValueType($yieldFromType); + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } + + break; } - $returnType = new GenericObjectType(Generator::class, [ - TypeCombinator::union(...$keyTypes), - TypeCombinator::union(...$valueTypes), - new MixedType(), - $returnType, - ]); - } else { - if ($expr->returnType !== null) { - $nativeReturnType = $scope->getFunctionType($expr->returnType, false, false); - $returnType = MutatingScope::intersectButNotNever($nativeReturnType, $returnType); + if (count($node->getStatementResult()->getExitPoints()) === 0) { + if ($onlyNeverExecutionEnds === null) { + $onlyNeverExecutionEnds = true; + } } + } else { + $onlyNeverExecutionEnds = false; } + } - $usedVariables = []; - foreach ($expr->uses as $use) { - if (!is_string($use->var->name)) { - continue; + return $onlyNeverExecutionEnds; + } + + /** + * Builds the closure/arrow function's declared parameters (independent of the + * body walk) and the callable parameter acceptors derived from the call site. + * + * @return array{list, bool, ParameterReflection[]|null, ParameterReflection[]|null} + */ + private function buildParametersAndAcceptors( + MutatingScope $scope, + Node\Expr\Closure|ArrowFunction $expr, + ): array + { + $parameters = []; + $isVariadic = false; + $firstOptionalParameterIndex = null; + foreach ($expr->params as $i => $param) { + $isOptionalCandidate = $param->default !== null || $param->variadic; + + if ($isOptionalCandidate) { + if ($firstOptionalParameterIndex === null) { + $firstOptionalParameterIndex = $i; } + } else { + $firstOptionalParameterIndex = null; + } + } - $usedVariables[] = $use->var->name; + foreach ($expr->params as $i => $param) { + if ($param->variadic) { + $isVariadic = true; } + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new ShouldNotHappenException(); + } + $parameters[] = new NativeParameterReflection( + $param->var->name, + $firstOptionalParameterIndex !== null && $i >= $firstOptionalParameterIndex, + $scope->getFunctionType($param->type, $scope->isParameterValueNullable($param), false), + $param->byRef + ? PassedByReference::createCreatesNewVariable() + : PassedByReference::createNo(), + $param->variadic, + // a default is a constant expression - price it without a scope + // walk, the same way parameter defaults are priced elsewhere + $param->default !== null ? $this->initializerExprTypeResolver->getType($param->default, InitializerExprContext::fromScope($scope)) : null, + ); + } - foreach ($expr->uses as $use) { - if (!$use->byRef) { - continue; + $callableParameters = null; + $nativeCallableParameters = null; + $arrayMapArgs = $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME); + $immediatelyInvokedArgs = $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME); + if ($arrayMapArgs !== null) { + $callableParameters = []; + $nativeCallableParameters = []; + foreach ($arrayMapArgs as $funcCallArg) { + $callableParameters[] = new DummyParameter('item', $scope->getType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($funcCallArg->value)->getIterableValueType(), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + } elseif ($immediatelyInvokedArgs !== null) { + foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { + $callableParameters[] = new DummyParameter('item', $scope->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallableParameters[] = new DummyParameter('item', $scope->getNativeType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + } + } else { + $inFunctionCallsStackCount = count($scope->inFunctionCallsStack); + if ($inFunctionCallsStackCount > 0) { + [, $inParameter] = $scope->inFunctionCallsStack[$inFunctionCallsStackCount - 1]; + if ($inParameter !== null) { + $callableParameters = $this->nodeScopeResolver->createCallableParameters($scope, $expr, null, $inParameter->getType()); + $nativeType = $inParameter instanceof ExtendedParameterReflection ? $inParameter->getNativeType() : $inParameter->getType(); + $nativeCallableParameters = $this->nodeScopeResolver->createNativeCallableParameters($scope, $expr, null, $nativeType); } + } + } - $impurePoints[] = new ImpurePoint( - $scope, - $expr, - 'functionCall', - 'call to a Closure with by-ref use', - true, - ); - break; + return [$parameters, $isVariadic, $callableParameters, $nativeCallableParameters]; + } + + /** + * @param list $parameters + * @param array{returnType: Type, throwPoints: SimpleThrowPoint[], impurePoints: SimpleImpurePoint[], invalidateExpressions: InvalidateExprNode[], usedVariables: string[]} $cachedClosureData + */ + private function createClosureTypeFromCache( + Node\Expr\Closure|ArrowFunction $expr, + array $parameters, + bool $isVariadic, + array $cachedClosureData, + ): ClosureType + { + $mustUseReturnValue = TrinaryLogic::createNo(); + foreach ($expr->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attr) { + if ($attr->name->toLowerString() === 'nodiscard') { + $mustUseReturnValue = TrinaryLogic::createYes(); + break; + } } } + return new ClosureType( + $parameters, + $cachedClosureData['returnType'], + $isVariadic, + TemplateTypeMap::createEmpty(), + TemplateTypeMap::createEmpty(), + TemplateTypeVarianceMap::createEmpty(), + throwPoints: $cachedClosureData['throwPoints'], + impurePoints: $cachedClosureData['impurePoints'], + invalidateExpressions: $cachedClosureData['invalidateExpressions'], + usedVariables: $cachedClosureData['usedVariables'], + acceptsNamedArguments: TrinaryLogic::createYes(), + mustUseReturnValue: $mustUseReturnValue, + isStatic: TrinaryLogic::createFromBoolean($expr->static), + ); + } + + /** + * Constructs the final ClosureType from the resolved return type and the + * gathered throw/impure/invalidate points. Adds the by-ref-parameter impure + * point, populates the per-scope phpdoc-type cache (closures only), and + * resolves the #[NoDiscard] attribute. + * + * @param list $parameters + * @param ThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + * @param string[] $usedVariables + */ + private function assembleClosureType( + MutatingScope $scope, + Node\Expr\Closure|ArrowFunction $expr, + array $parameters, + bool $isVariadic, + Type $returnType, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + array $usedVariables, + ?string $cacheKey = null, + ): ClosureType + { foreach ($parameters as $parameter) { if ($parameter->passedByReference()->no()) { continue; @@ -422,15 +834,16 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu $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); - $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); - $cachedTypes[$scope->getClosureScopeCacheKey()] = [ + $cachedTypes = $this->cachedTypes[$expr] ?? []; + $cacheKey ??= $this->closureContextCacheKey($scope, $expr, null, $parameters); + $cachedTypes[$cacheKey] = [ 'returnType' => $returnType, 'throwPoints' => $throwPointsForClosureType, 'impurePoints' => $impurePointsForClosureType, 'invalidateExpressions' => $invalidateExpressions, 'usedVariables' => $usedVariables, ]; - $expr->setAttribute('phpstanCachedTypes', $cachedTypes); + $this->cachedTypes[$expr] = $cachedTypes; $mustUseReturnValue = TrinaryLogic::createNo(); foreach ($expr->attrGroups as $attrGroup) { diff --git a/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php new file mode 100644 index 00000000000..9b84d94eac8 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/CoalesceCompositionHelper.php @@ -0,0 +1,113 @@ +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 `??`'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 + { + $result = $leftResult->getIssetabilityResolution($evaluationScope, false)->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): Type { + $leftIssetTypes = $this->defaultNarrowingHelper->createIssetTruthyChainTypes( + $evaluationScope, + $leftExpr, + $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $evaluationScope, $nodeScopeResolver), + $rootExpr, + TypeSpecifierContext::createTruthy(), + ); + $leftIsSetScope = $evaluationScope->applySpecifiedTypes($leftIssetTypes); + $leftType = $leftResult->answersOnScope($leftIsSetScope, false) + ? $leftResult->getTypeOnScope($leftIsSetScope, false) + : $nodeScopeResolver->processExprOnDemand($leftExpr, $leftIsSetScope, new ExpressionResultStorage())->getType(); + + 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/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index f0f6b533e3d..eb3002af8a0 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -7,268 +7,191 @@ use PhpParser\Node\Expr\BinaryOp\BooleanOr; use PhpParser\Node\Expr\BinaryOp\LogicalAnd; use PhpParser\Node\Expr\BinaryOp\LogicalOr; -use PHPStan\Analyser\ConditionalExpressionHolder; -use PHPStan\Analyser\ExpressionTypeHolder; +use PHPStan\Analyser\ConditionalExpressionHolderRecipe; +use PHPStan\Analyser\DisjunctionBranchUnionAugment; use PHPStan\Analyser\MutatingScope; -use PHPStan\Analyser\Scope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\SpecifiedTypes; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierContext; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Type\NeverType; -use PHPStan\Type\TypeCombinator; -use function array_key_exists; -use function count; 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, ) { } - public function augmentDisjunctionTypes( - MutatingScope $scope, - MutatingScope $rightScope, - SpecifiedTypes $leftNormalized, - SpecifiedTypes $rightNormalized, - Expr $leftExpr, - Expr $rightExpr, - bool $truthy, + /** + * Captures the either-branch union recovery as a deferred augment: the + * branch types are read from the operand-walk filtered scopes here at + * compose time, while the does-it-actually-narrow gates run against the + * applying scope when MutatingScope::applySpecifiedTypes() evaluates it. + * + * The filtered scopes are thunks resolved only when there are candidate + * expressions - deriving them per level of a deep boolean chain is + * quadratic. + * + * @param callable(): MutatingScope $leftFilteredScope + * @param callable(): MutatingScope $rightFilteredScope + */ + public function buildBranchUnionAugment( + NodeScopeResolver $nodeScopeResolver, + SpecifiedTypes $leftTypes, + SpecifiedTypes $rightTypes, + callable $leftFilteredScope, + callable $rightFilteredScope, SpecifiedTypes $types, - ): SpecifiedTypes + ): ?DisjunctionBranchUnionAugment { $candidateExprs = []; - foreach ($leftNormalized->getSureTypes() as $exprString => [$exprNode, $type]) { + foreach ($leftTypes->getSureTypes() as $exprString => [$exprNode, $type]) { + $candidateExprs[$exprString] = $exprNode; + } + foreach ($rightTypes->getSureTypes() as $exprString => [$exprNode, $type]) { + $candidateExprs[$exprString] = $exprNode; + } + // sureNot entries constrain their branch too - the old normalize() + // converted them to sure entries before candidates were collected, so a + // sureNot-only narrowing (e.g. the truthy of a bool variable) must also + // contribute its subject. The branch-scope reads below price the subject + // on each filtered scope, where an impossible branch (a holder-fixpoint + // contradiction) collapses to never and drops out of the union. + foreach ($leftTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { $candidateExprs[$exprString] = $exprNode; } - foreach ($rightNormalized->getSureTypes() as $exprString => [$exprNode, $type]) { + foreach ($rightTypes->getSureNotTypes() as $exprString => [$exprNode, $type]) { $candidateExprs[$exprString] = $exprNode; } $existingSureTypes = $types->getSureTypes(); $existingAlternativeTypes = $types->getAlternativeTypes(); - $viableCandidates = []; + $candidates = []; + $leftScope = null; + $rightScope = null; foreach ($candidateExprs as $exprString => $targetExpr) { if (isset($existingSureTypes[$exprString]) || isset($existingAlternativeTypes[$exprString])) { // an alternative-form entry already encodes the either-branch // union for this expression, deferred to the application point continue; } - if (!$scope->hasExpressionType($targetExpr)->yes()) { - continue; - } - $viableCandidates[$exprString] = $targetExpr; - } - - if ($viableCandidates === []) { - return $types; - } - - if ($truthy) { - $leftFilteredScope = $scope->filterByTruthyValue($leftExpr); - $rightFilteredScope = $rightScope->filterByTruthyValue($rightExpr); - } else { - $leftFilteredScope = $scope->filterByFalseyValue($leftExpr); - $rightFilteredScope = $rightScope->filterByFalseyValue($rightExpr); - } - - foreach ($viableCandidates as $targetExpr) { - if (!$leftFilteredScope->hasExpressionType($targetExpr)->yes()) { - continue; - } - if (!$rightFilteredScope->hasExpressionType($targetExpr)->yes()) { + // 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; } - - $originalType = $scope->getType($targetExpr); - $leftType = $leftFilteredScope->getType($targetExpr); - $rightType = $rightFilteredScope->getType($targetExpr); - - if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { + $leftScope ??= $leftFilteredScope(); + $rightScope ??= $rightFilteredScope(); + if (!$leftScope->hasExpressionType($targetExpr)->yes()) { continue; } - - if ($rightType->equals($originalType) || !$originalType->isSuperTypeOf($rightType)->yes()) { + if (!$rightScope->hasExpressionType($targetExpr)->yes()) { continue; } - $unionType = TypeCombinator::union($leftType, $rightType); - if ($unionType->equals($originalType)) { - continue; - } + // the operands were processed during processExpr; read their stored + // results on these filtered scopes instead of re-walking via getType(). + $candidates[] = [ + $targetExpr, + $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $leftScope), + $nodeScopeResolver->readTypeOfMaybeStored($targetExpr, $rightScope), + ]; + } - $types = $types->unionWith( - $this->typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), - ); + if ($candidates === []) { + return null; } - return $types; + return new DisjunctionBranchUnionAugment($nodeScopeResolver, $this->defaultNarrowingHelper, $candidates); } /** - * Combines several `processBooleanConditionalTypes()` results into one map. + * Captures the raw entries of a boolean-decomposition holder pair as a + * recipe; the state-dependent complement/target math runs against the + * applying scope when MutatingScope::applySpecifiedTypes() evaluates it. * - * A plain `array_merge()` would be keyed by the target expression string and - * therefore let a later result overwrite an earlier one targeting the same - * expression, silently dropping a holder. Holders for the same expression are - * unioned by their key instead so all of them survive. + * The condition side asserts that its sub-expression evaluates truthy. + * When that sub-expression is itself a compound boolean (e.g. `$a && $b`), + * the narrowings making it true are spread across both the sure and + * sureNot lists of its specification. All of them are conjuncts of the + * single "this side is true" condition, so they must be gathered together + * into one condition set. Picking only one list would drop a conjunct and + * let the resulting holder fire too eagerly. * - * @param list> $holderLists - * @return array + * @param MutatingScope|null $nonVariableTargetScope the operand-walk scope non-variable + * holder targets were tracked on; their types are pinned from it at compose + * time (null = read every target from the applying scope) */ - public function mergeConditionalHolders(array $holderLists): array + public function buildConditionalHolderRecipe(SpecifiedTypes $conditionSpecifiedTypes, SpecifiedTypes $holderSpecifiedTypes, bool $holdersFromSureTypes, bool $holderSideIsNegated, ?MutatingScope $nonVariableTargetScope, ?Expr $holderSideExpr = null): ?ConditionalExpressionHolderRecipe { - $result = []; - foreach ($holderLists as $holders) { - foreach ($holders as $exprString => $exprHolders) { - foreach ($exprHolders as $key => $holder) { - $result[$exprString][$key] = $holder; - } - } + // an alternative-form entry (a cross-kind either-branch merge) has no + // single condition type; dropping it from the condition set would let + // the holder fire too eagerly - build no holders from such a condition + if ($conditionSpecifiedTypes->getAlternativeTypes() !== []) { + return null; } - return $result; - } + // A holder side that is itself a compound boolean cannot always be split + // into independent per-expression holders. In the `BooleanAnd` false + // context the holder asserts its side is false: when that side is a + // conjunction (`$a && $b`), its negation is the disjunction `!$a || !$b`, + // which has no per-expression narrowing — narrowing each conjunct + // independently would drop a reachable value (e.g. `$a = false, $b = true`). + // Symmetrically, in the `BooleanOr` true context the holder asserts its + // side is true, and a disjunction side (`$a || $b`) is itself a disjunction. + // Such a side is left whole rather than split into over-narrowing holders. + if ($this->isUnsplittableCompoundHolderSide($holderSideExpr, $holderSideIsNegated)) { + return null; + } - /** - * @return array - */ - public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $conditionSpecifiedTypes, SpecifiedTypes $holderSpecifiedTypes, bool $holdersFromSureTypes, bool $holderSideIsNegated, Scope $rightScope, ?Expr $holderSideExpr = null): array - { - // The condition side asserts that its sub-expression evaluates truthy. - // When that sub-expression is itself a compound boolean (e.g. `$a && $b`), - // the narrowings making it true are spread across both the sure and - // sureNot lists of its specification. All of them are conjuncts of the - // single "this side is true" condition, so they must be gathered together - // into one condition set. Picking only one list would drop a conjunct and - // let the resulting holder fire too eagerly. - $conditionExpressionTypes = []; - $droppedNoOpConditions = []; + $conditionEntries = []; foreach ($conditionSpecifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { if (!$this->isTrackableExpression($expr)) { continue; } - $scopeType = $scope->getType($expr); - $conditionType = TypeCombinator::remove($scopeType, $type); - if ($scopeType->equals($conditionType)) { - $droppedNoOpConditions[$exprString] = true; - continue; - } - - $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes( - $expr, - $conditionType, - ); + $conditionEntries[] = [(string) $exprString, $expr, true, $type]; } foreach ($conditionSpecifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { if (!$this->isTrackableExpression($expr)) { continue; } - $scopeType = $scope->getType($expr); - $conditionType = TypeCombinator::intersect($scopeType, $type); - if ($scopeType->equals($conditionType)) { - $droppedNoOpConditions[$exprString] = true; - continue; - } - - $conditionExpressionTypes[$exprString] = ExpressionTypeHolder::createYes( - $expr, - $conditionType, - ); + $conditionEntries[] = [(string) $exprString, $expr, false, $type]; } - if (count($conditionExpressionTypes) > 0) { - $holders = []; - $holderTypes = $holdersFromSureTypes ? $holderSpecifiedTypes->getSureTypes() : $holderSpecifiedTypes->getSureNotTypes(); + if ($conditionEntries === []) { + return null; + } - // A holder side that is itself a compound boolean cannot always be split - // into independent per-expression holders. In the `BooleanAnd` false - // context the holder asserts its side is false: when that side is a - // conjunction (`$a && $b`), its negation is the disjunction `!$a || !$b`, - // which has no per-expression narrowing — narrowing each conjunct - // independently would drop a reachable value (e.g. `$a = false, $b = true`). - // Symmetrically, in the `BooleanOr` true context the holder asserts its - // side is true, and a disjunction side (`$a || $b`) is itself a disjunction. - // Such a side is left whole rather than split into over-narrowing holders. - if ($this->isUnsplittableCompoundHolderSide($holderSideExpr, $holderSideIsNegated)) { - return []; + $holderEntries = []; + $holderTypes = $holdersFromSureTypes ? $holderSpecifiedTypes->getSureTypes() : $holderSpecifiedTypes->getSureNotTypes(); + foreach ($holderTypes as $exprString => [$expr, $type]) { + if (!$this->isTrackableExpression($expr)) { + continue; } - foreach ($holderTypes as $exprString => [$expr, $type]) { - if (!$this->isTrackableExpression($expr)) { - continue; - } - - // The target's only link to the antecedent was a no-op relation (e.g. - // `$a === $b`) that got dropped, so the antecedent no longer constrains - // it. Projecting a consequent onto it would fire unsoundly. Skip it. - if (array_key_exists($exprString, $droppedNoOpConditions)) { - continue; - } - - $conditions = $conditionExpressionTypes; - $droppedSelfCondition = null; - foreach ($conditions as $conditionExprString => $condition) { - if ($conditionExprString !== $exprString) { - continue; - } - $droppedSelfCondition = $condition; - unset($conditions[$conditionExprString]); - } - - if (count($conditions) === 0) { - continue; - } - - $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; - $targetType = $targetScope->getType($expr); - $holderType = $holdersFromSureTypes - ? TypeCombinator::intersect($targetType, $type) - : TypeCombinator::remove($targetType, $type); - - // The dropped self-condition narrowed the target; without it the - // holder must allow the values it excluded, or it over-narrows when - // only the remaining conditions hold. So union back the complement. - if ($droppedSelfCondition !== null) { - $complement = TypeCombinator::remove($scope->getType($expr), $droppedSelfCondition->getType()); - if (!$complement instanceof NeverType) { - $holderType = TypeCombinator::union($holderType, $complement); - } - } - - // These boolean-decomposition holders only refine an expression's - // type in a future scope; they must never collapse it to never and - // thereby mark the whole scope unreachable. A never result is an - // artifact (e.g. removing a non-nullable property's full type after - // swapping isset() narrowing), not a real contradiction. - if ($holderType instanceof NeverType && !$targetType instanceof NeverType) { - continue; - } - $holder = new ConditionalExpressionHolder( - $conditions, - ExpressionTypeHolder::createYes($expr, $holderType), - ); - $holders[$exprString] ??= []; - $holders[$exprString][$holder->getKey()] = $holder; - } + $pinnedTargetType = !$expr instanceof Expr\Variable && $nonVariableTargetScope !== null + ? $nonVariableTargetScope->getStateType($expr) + : null; + $holderEntries[] = [(string) $exprString, $expr, $type, $pinnedTargetType]; + } - return $holders; + if ($holderEntries === []) { + return null; } - return []; + return new ConditionalExpressionHolderRecipe($conditionEntries, $holderEntries, $holdersFromSureTypes); } /** @@ -301,33 +224,4 @@ private function isTrackableExpression(Expr $expr): bool || $expr instanceof Expr\StaticPropertyFetch; } - /** - * The eager form of the old SpecifiedTypes::normalize(): folds sure-not - * entries into sure entries by subtracting from the expression's type on - * the given scope. Only for consumers that need concrete sure types at - * composition time (conditional-holder building, decided operands) - - * merge paths use SpecifiedTypes::intersectWith() and evaluate at the - * application point instead. - */ - public function toSureTypes(SpecifiedTypes $types, Scope $scope): SpecifiedTypes - { - $sureTypes = $types->getSureTypes(); - - foreach ($types->getSureNotTypes() as $exprString => [$exprNode, $sureNotType]) { - if (!isset($sureTypes[$exprString])) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($scope->getType($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()); - } - } 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..89690ca5f81 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -0,0 +1,913 @@ +` - 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) { + 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()) { + if ($context->true()) { + $nullRuledOut = $type->isNull()->no() || ($s->nativeTypesPromoted ? $subjectResult->getNativeType() : $subjectResult->getType())->isNull()->no(); + } elseif ($context->false()) { + $nullRuledOut = TypeCombinator::containsNull($type) || ($s->nativeTypesPromoted ? $subjectResult->getNativeType() : $subjectResult->getType())->isNull()->no(); + } else { + $nullRuledOut = false; + } + + 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; + } + + /** + * 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: an already-processed + * link resolves through its result on the asking scope (honouring narrowing), + * anything else through the maybe-stored fallback. + * + * @param array $chainResults + * @return Closure(Expr): Type + */ + public function buildChainTypeReader(array $chainResults, MutatingScope $s, NodeScopeResolver $nodeScopeResolver): Closure + { + return static function (Expr $e) use ($chainResults, $s, $nodeScopeResolver): Type { + $result = $chainResults[spl_object_id($e)] ?? null; + + return $result !== null ? $result->getTypeOnScope($s, $s->nativeTypesPromoted) : $nodeScopeResolver->readTypeOfMaybeStored($e, $s); + }; + } + + /** + * 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 + ) { + $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->isRememberableCallSubject($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->isRememberableCallSubject($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 isRememberableCallSubject(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(); + } + +} 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/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..aac04468ea0 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/IdenticalNarrowingHelper.php @@ -0,0 +1,1170 @@ +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; + } + $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 - not composed + return null; + } + + 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/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index 58ed6c39ac6..9bb35b0d870 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -9,8 +9,11 @@ use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\SpecifiedTypes; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; +use PHPStan\Type\MixedType; use function sprintf; #[AutowiredService] @@ -25,12 +28,21 @@ public function __construct( { } - public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): ExpressionResult + /** + * @param ExpressionResult|null $exprResult the already-computed result of $expr, + * passed by callers that processed it on $scope so this helper reads its type + * directly instead of re-walking via Scope::getType(); callers that do not + * hold the result (only the Expr) pass null and the type is read from the + * stored result or priced on demand + */ + public function processImplicitToStringCall(NodeScopeResolver $nodeScopeResolver, Expr $expr, MutatingScope $scope, ?ExpressionResult $exprResult = null): ExpressionResult { $throwPoints = []; $impurePoints = []; - $exprType = $scope->getType($expr); + $exprType = $exprResult !== null + ? $exprResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($expr, $scope); $toStringMethod = null; if (!$exprType->isObject()->no()) { @@ -45,6 +57,8 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), ); } @@ -59,12 +73,16 @@ public function processImplicitToStringCall(Expr $expr, MutatingScope $scope): E } if ($this->phpVersion->throwsOnStringCast()) { + // the __toString() call is a synthetic node - price it on demand to + // resolve its return type instead of re-walking via Scope::getType(). + $toStringCall = new Expr\MethodCall($expr, new Identifier('__toString')); $throwPoint = $this->methodThrowPointHelper->getThrowPoint( $toStringMethod, $toStringMethod->getOnlyVariant(), - new Expr\MethodCall($expr, new Identifier('__toString')), + $toStringCall, $scope, ExpressionContext::createDeep(), + $nodeScopeResolver->processSyntheticOnDemand($toStringCall, $scope)->getTypeOnScope($scope, $scope->nativeTypesPromoted), ); if ($throwPoint !== null) { $throwPoints[] = $throwPoint; @@ -79,6 +97,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/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/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 2658beb2ce0..a70730fa610 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -17,6 +17,7 @@ use PHPStan\Type\DynamicStaticMethodThrowTypeExtension; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use ReflectionFunction; use ReflectionMethod; use Throwable; @@ -41,12 +42,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) { @@ -87,8 +95,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); } } @@ -98,8 +105,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); } } diff --git a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php index 647036c7c80..46301bf00a2 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -12,18 +12,80 @@ use PHPStan\Analyser\EnsuredNonNullabilityResult; use PHPStan\Analyser\EnsuredNonNullabilityResultExpression; use PHPStan\Analyser\MutatingScope; -use PHPStan\Analyser\Scope; 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 { - 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) { - $exprType = $scope->getType($exprToSpecify); + } + + /** + * 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 + { + $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 +95,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 +115,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 +130,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 +150,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 +168,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 +188,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/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/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/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..e566633bdbb 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($nodeScopeResolver, $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/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index 9fc6c135a1c..c7f149fca05 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -3,53 +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\NonNullabilityHelper; 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\Expr\TypeExpr; -use PHPStan\Node\IssetExpr; -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\Node\IssetExpressionNode; 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 @@ -61,6 +44,8 @@ final class IssetHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -70,280 +55,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 - ) { - $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; @@ -352,10 +63,12 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $nonNullabilityResults = []; $isAlwaysTerminating = false; + $varResults = []; foreach ($expr->vars as $var) { $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $var); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $var); $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, $nodeCallback, $context->enterDeep()); + $varResults[] = $varResult; $scope = $varResult->getScope(); $hasYield = $hasYield || $varResult->hasYield(); $throwPoints = array_merge($throwPoints, $varResult->getThrowPoints()); @@ -367,7 +80,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; } @@ -388,6 +101,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, @@ -396,6 +131,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, $nodeScopeResolver); + + 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 ($nodeScopeResolver, $chainResults, $expr, $var, $varResult): SpecifiedTypes { + $scopedReadType = $this->defaultNarrowingHelper->buildChainTypeReader($chainResults, $scope, $nodeScopeResolver); + 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/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index a0d69d5d70d..7ec93260b44 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; @@ -40,6 +42,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnionType; use UnhandledMatchError; +use WeakMap; use function array_key_exists; use function array_merge; use function array_values; @@ -52,15 +55,27 @@ * @implements ExprHandler */ #[AutowiredService] -final class MatchHandler implements ExprHandler +final class MatchHandler implements ExprHandler, PerFileAnalysisResettable { + /** @var WeakMap> */ + private WeakMap $capturedArmResults; + + public function resetFileAnalysisState(): void + { + $this->capturedArmResults = new WeakMap(); + } + public function __construct( #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private IdenticalNarrowingHelper $identicalNarrowingHelper, + private RicherScopeGetTypeHelper $richerScopeGetTypeHelper, ) { + $this->capturedArmResults = new WeakMap(); } public function supports(Expr $expr): bool @@ -68,143 +83,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[$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[$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 +113,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 +129,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 +258,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 +283,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 +310,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 +321,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if (!$armResult->isAlwaysTerminating()) { $armBodyScopes[] = $matchScope; } + $armTypeResults[] = [$armResult, $defaultArmBodyScope, $arm->body]; continue; } @@ -411,10 +330,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 +347,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 +429,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 +449,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 +485,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->cond = $expr->cond->getExpr(); } + $this->capturedArmResults[$expr] = $armTypeResults; + return $this->expressionResultFactory->create( $scope, beforeScope: $beforeScope, @@ -512,6 +495,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 +558,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 +592,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/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index f5704cccdb5..2c4f9ed0d24 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,6 +31,7 @@ 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\Type\ErrorType; @@ -57,13 +57,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 +85,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(), ); } @@ -99,24 +106,34 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->restoreOriginalScopeAfterClosureBind($originalScope); } $parametersAcceptor = null; + $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); if ($methodReflection !== null) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $methodReflection->getVariants(), - $methodReflection->getNamedArgumentsVariants(), - ); - + $variants = $methodReflection->getVariants(); + $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); + // A structural acceptor (names/positions/variadic) drives argument + // normalization, the impure point and the throw point - generics are + // resolved type-driven by processArgs() into $resolvedParametersAcceptor. + $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) { @@ -145,17 +162,112 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $stmt, $methodReflection, $methodReflection !== null ? $scope->getNakedMethod($calledOnType, $methodReflection->getName()) : null, - $parametersAcceptor, + $variants, + $namedArgumentsVariants, $normalizedExpr, $scope, $storage, $nodeCallback, $context, ); + $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( + $nodeScopeResolver, + $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)) { + return new SpecifiedTypes([], []); + } + + // 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) { - $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context); + // 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 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; } @@ -164,24 +276,30 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass()); } elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin()) { + // the remembered call value and the @phpstan-self-out type are + // generic-sensitive: resolve them from the type-driven acceptor + // processArgs() selected (generics resolved against the actual arg + // types), falling back to the structural acceptor for dynamic callees. + $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr->var, sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), - $parametersAcceptor->getReturnType(), + $acceptorForGenerics->getReturnType(), new MixedType(), ); } if (!$methodReflection->isStatic()) { $selfOutType = $methodReflection->getSelfOutType(); if ($selfOutType !== null) { + $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; $scope = $scope->assignExpression( $normalizedExpr->var, TemplateTypeHelper::resolveTemplateTypes( $selfOutType, - $parametersAcceptor->getResolvedTemplateTypeMap(), - $parametersAcceptor instanceof ExtendedParametersAcceptor ? $parametersAcceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + $acceptorForGenerics->getResolvedTemplateTypeMap(), + $acceptorForGenerics instanceof ExtendedParametersAcceptor ? $acceptorForGenerics->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), TemplateTypeVariance::createCovariant(), ), - $scope->getNativeType($normalizedExpr->var), + $varResult->getNativeType(), ); } } @@ -203,17 +321,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, - ); + $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; } @@ -239,6 +351,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, ); } } @@ -246,72 +362,104 @@ 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(NodeScopeResolver $nodeScopeResolver, 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. + $nameType = $nameResult !== null + ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $reflectionScope); 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 ( @@ -319,7 +467,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; } @@ -328,33 +476,71 @@ 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 + { + if (!$this->isMethodCallNarrowable($scope, $expr, $varResult)) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + /** @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 3598f135a38..098f1ae3e8a 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -14,6 +14,8 @@ 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; use PHPStan\Analyser\MutatingScope; @@ -25,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; @@ -36,6 +37,7 @@ use PHPStan\Reflection\Callables\SimpleImpurePoint; use PHPStan\Reflection\ClassReflection; use PHPStan\Reflection\Dummy\DummyConstructorReflection; +use PHPStan\Reflection\ExtendedMethodReflection; use PHPStan\Reflection\ExtendedParametersAcceptor; use PHPStan\Reflection\MethodReflection; use PHPStan\Reflection\ParametersAcceptor; @@ -86,6 +88,7 @@ public function __construct( #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -107,6 +110,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = []; $isAlwaysTerminating = false; $normalizedExpr = $expr; + $className = null; if ($expr->class instanceof Name) { $className = $scope->resolveName($expr->class); @@ -121,17 +125,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $classReflection = $this->reflectionProvider->getAnonymousClassReflection($expr->class, $scope); // populates $expr->class->name if ($classReflection->hasConstructor()) { $constructorReflection = $classReflection->getConstructor(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $constructorReflection->getVariants(), - $constructorReflection->getNamedArgumentsVariants(), - ); + // A structural acceptor (names/positions/variadic) drives argument + // normalization and the throw point - generics are resolved + // type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); if ($constructorReflection->getDeclaringClass()->getName() === $classReflection->getName()) { $constructorResult = null; - $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $classReflection, &$constructorResult): void { - $nodeCallback($node, $scope); + $nodeScopeResolver->processStmtNode($expr->class, $scope, $storage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($classReflection, &$constructorResult): void { if (!$node instanceof MethodReturnStatementsNode) { return; } @@ -149,7 +150,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex return; } $constructorResult = $node; - }, StatementContext::createTopLevel()); + }, $nodeCallback), StatementContext::createTopLevel()); if ($constructorResult !== null) { $throwPoints = array_map(static fn (ThrowPoint $point): InternalThrowPoint => InternalThrowPoint::createFromPublic($point, $scope), $constructorResult->getStatementResult()->getThrowPoints()); $impurePoints = $constructorResult->getImpurePoints(); @@ -170,9 +171,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]; @@ -182,12 +198,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) { @@ -208,13 +218,57 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - $argsResult = $nodeScopeResolver->processArgs($stmt, $constructorReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallback, $context); + $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( + $nodeScopeResolver, + $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, + $expr, + $nativeTypesPromoted ? null : $resolvedParametersAcceptor, + ); + $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); @@ -233,19 +287,11 @@ 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); } /** - * @return array{?MethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} + * @return array{?ExtendedMethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} */ private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope, bool $isDynamic): array { @@ -258,12 +304,10 @@ private function processConstructorReflection(string $className, New_ $expr, Mut $classReflection = $this->reflectionProvider->getClass($className); if ($classReflection->hasConstructor()) { $constructorReflection = $classReflection->getConstructor(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $constructorReflection->getVariants(), - $constructorReflection->getNamedArgumentsVariants(), - ); + // A structural acceptor (names/positions/variadic) drives argument + // normalization and the throw point - generics are resolved + // type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants()); } } @@ -342,10 +386,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(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr, ?ParametersAcceptor $preResolvedAcceptor): 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); @@ -353,11 +406,13 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return new ObjectType($anonymousClassReflection->getName()); } - $exprType = $scope->getType($expr->class); + // the class expression was processed during processExpr; read its already + // computed result instead of re-walking via Scope::getType(). + $exprType = $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $scope); return $exprType->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; @@ -403,8 +458,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(), @@ -434,9 +488,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); @@ -639,13 +705,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()); @@ -654,17 +731,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; @@ -672,6 +747,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/NullsafeMethodCallHandler.php b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php index 524cad05a6d..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,14 +14,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\NullsafeMethodCallExpressionNode; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Type\NullType; use PHPStan\Type\Type; @@ -39,6 +39,8 @@ final class NullsafeMethodCallHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -48,59 +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; $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, @@ -108,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; @@ -118,6 +104,33 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } + // 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, beforeScope: $beforeScope, @@ -126,6 +139,85 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, 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 f2d843625b0..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,14 +14,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\NullsafePropertyFetchExpressionNode; use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Type\NullType; use PHPStan\Type\Type; @@ -39,6 +39,8 @@ final class NullsafePropertyFetchHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { } @@ -48,55 +50,67 @@ 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; + // 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 `?->`'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, beforeScope: $beforeScope, @@ -105,6 +119,85 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: false, 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/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/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/ExprHandler/PrintHandler.php b/src/Analyser/ExprHandler/PrintHandler.php index cd6a90aee17..e6b3149db8e 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($nodeScopeResolver, $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/PropertyFetchHandler.php b/src/Analyser/ExprHandler/PropertyFetchHandler.php index 8231d9da28b..bc8dc86f4a2 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -5,23 +5,22 @@ 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\Type\ErrorType; use PHPStan\Type\MixedType; @@ -42,6 +41,7 @@ public function __construct( private PhpVersion $phpVersion, private PropertyReflectionFinder $propertyReflectionFinder, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -61,6 +61,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $varResult->getImpurePoints(); $isAlwaysTerminating = $varResult->isAlwaysTerminating(); $scope = $varResult->getScope(); + $nameResult = null; if ($expr->name instanceof Identifier) { if ($this->phpVersion->supportsPropertyHooks()) { $propertyName = $expr->name->toString(); @@ -94,52 +95,64 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, 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, $nodeScopeResolver, $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. + $nameType = $nameResult !== null + ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $beforeScope); + 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, PropertyFetch $propertyFetch): ?Type @@ -156,9 +169,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/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 new file mode 100644 index 00000000000..27c71f8c2d2 --- /dev/null +++ b/src/Analyser/ExprHandler/ShellExecHandler.php @@ -0,0 +1,87 @@ + + */ +#[AutowiredService] +final class ShellExecHandler implements ExprHandler +{ + + public function __construct( + private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + + public function supports(Expr $expr): bool + { + return $expr instanceof ShellExec; + } + + public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + { + $beforeScope = $scope; + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $isAlwaysTerminating = false; + foreach ($expr->parts as $part) { + if (!$part instanceof Expr) { + continue; + } + $partResult = $nodeScopeResolver->processExprNode($stmt, $part, $scope, $storage, $nodeCallback, $context->enterDeep()); + $hasYield = $hasYield || $partResult->hasYield(); + $throwPoints = array_merge($throwPoints, $partResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $partResult->getImpurePoints()); + + $toStringResult = $this->implicitToStringCallHelper->processImplicitToStringCall($nodeScopeResolver, $part, $scope, $partResult); + $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); + + $isAlwaysTerminating = $isAlwaysTerminating || $partResult->isAlwaysTerminating(); + $scope = $partResult->getScope(); + } + + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + 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), + ); + } + +} diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 280adc40833..99553c4b3cb 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,6 +34,7 @@ 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\Type\ErrorType; @@ -65,13 +64,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, ) { } @@ -88,6 +89,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $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(); @@ -96,80 +100,102 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $scope = $classResult->getScope(); + $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; $methodReflection = null; - $closureBindScope = null; + $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); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $methodReflection->getVariants(), - $methodReflection->getNamedArgumentsVariants(), - ); + $variants = $methodReflection->getVariants(); + $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); + // A structural acceptor (names/positions/variadic) drives argument + // normalization, the impure point and the throw point - generics are + // resolved type-driven by processArgs() into $resolvedParametersAcceptor. + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); $declaringClass = $methodReflection->getDeclaringClass(); if ( $declaringClass->getName() === 'Closure' && strtolower($methodName) === 'bind' ) { - $thisType = null; - $nativeThisType = null; - if (isset($expr->getArgs()[1])) { - $argType = $scope->getType($expr->getArgs()[1]->value); - if ($argType->isNull()->yes()) { - $thisType = null; - } else { - $thisType = $argType; - } + $closureBindScopeFactory = static function (MutatingScope $boundScope) use ($expr): MutatingScope { + $thisType = null; + $nativeThisType = null; + if (isset($expr->getArgs()[1])) { + $argType = $boundScope->getType($expr->getArgs()[1]->value); + if ($argType->isNull()->yes()) { + $thisType = null; + } else { + $thisType = $argType; + } - $nativeArgType = $scope->getNativeType($expr->getArgs()[1]->value); - if ($nativeArgType->isNull()->yes()) { - $nativeThisType = null; - } else { - $nativeThisType = $nativeArgType; + $nativeArgType = $boundScope->getNativeType($expr->getArgs()[1]->value); + if ($nativeArgType->isNull()->yes()) { + $nativeThisType = null; + } else { + $nativeThisType = $nativeArgType; + } } - } - $scopeClasses = ['static']; - if (isset($expr->getArgs()[2])) { - $argValue = $expr->getArgs()[2]->value; - $argValueType = $scope->getType($argValue); - - $directClassNames = $argValueType->getObjectClassNames(); - if (count($directClassNames) > 0) { - $scopeClasses = $directClassNames; - $thisTypes = []; - foreach ($directClassNames as $directClassName) { - $thisTypes[] = new ObjectType($directClassName); + $scopeClasses = ['static']; + if (isset($expr->getArgs()[2])) { + $argValue = $expr->getArgs()[2]->value; + $argValueType = $boundScope->getType($argValue); + + $directClassNames = $argValueType->getObjectClassNames(); + if (count($directClassNames) > 0) { + $scopeClasses = $directClassNames; + $thisTypes = []; + foreach ($directClassNames as $directClassName) { + $thisTypes[] = new ObjectType($directClassName); + } + $thisType = TypeCombinator::union(...$thisTypes); + } else { + $thisType = $argValueType->getClassStringObjectType(); + $scopeClasses = $thisType->getObjectClassNames(); } - $thisType = TypeCombinator::union(...$thisTypes); - } else { - $thisType = $argValueType->getClassStringObjectType(); - $scopeClasses = $thisType->getObjectClassNames(); } - } - $closureBindScope = $scope->enterClosureBind($thisType, $nativeThisType, $scopeClasses); + return $boundScope->enterClosureBind($thisType, $nativeThisType, $scopeClasses); + }; } } else { $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) { - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $expr->getArgs(), - $methodReflection->getVariants(), - $methodReflection->getNamedArgumentsVariants(), - ); + $variants = $methodReflection->getVariants(); + $namedArgumentsVariants = $methodReflection->getNamedArgumentsVariants(); + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization($expr->getArgs(), $variants, $namedArgumentsVariants); } } } else { @@ -181,7 +207,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(); } @@ -217,12 +245,93 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $returnType = $parametersAcceptor->getReturnType(); $isAlwaysTerminating = $isAlwaysTerminating || ($returnType instanceof NeverType && $returnType->isExplicit()); } - $argsResult = $nodeScopeResolver->processArgs($stmt, $methodReflection, null, $parametersAcceptor, $normalizedExpr, $scope, $storage, $nodeCallback, $context, $closureBindScope); + $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) { - $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; } @@ -251,9 +360,13 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin() ) { + // the remembered call value is generic-sensitive: resolve it from the + // type-driven acceptor processArgs() selected (generics resolved against + // the actual arg types), falling back to the structural acceptor. + $acceptorForGenerics = $resolvedParametersAcceptor ?? $parametersAcceptor; $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, new Variable('this'), sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), - $parametersAcceptor->getReturnType(), + $acceptorForGenerics->getReturnType(), new MixedType(), ); } @@ -291,130 +404,132 @@ 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, - ); + 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, $nodeScopeResolver, $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); + $staticMethodCalledOnType = $classType ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $reflectionScope->doNotTreatPhpDocTypesAsCertain()); } - $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(); + return new ErrorType(); } - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $callType); - } - - 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(); + $resolvedClassType = $classType ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $reflectionScope); + $staticMethodCalledOnType = TypeCombinator::removeNull($resolvedClassType)->getObjectTypeOrClassStringObjectType(); } - $callType = $this->methodCallReturnTypeHelper->methodCallReturnType( - $scope, + return $this->methodCallReturnTypeHelper->methodCallReturnType( + $reflectionScope, $staticMethodCalledOnType, - $expr->name->toString(), - $expr, - ); - if ($callType === null) { - $callType = new ErrorType(); - } - - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $callType); - } + $methodName, + $staticCall, + $preResolvedAcceptor, + $argsResult, + ) ?? new ErrorType(); + }; - return $callType; + if ($expr->name instanceof Identifier) { + return $shortCircuit($resolveStaticMethod($expr->name->toString(), $expr)); } - $nameType = $scope->getType($expr->name); + // 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. + $nameType = $nameResult !== null + ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $reflectionScope); 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; read its + // already-computed result instead of re-walking via Scope::getType(). + $calleeType = $classResult !== null + ? $classResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $scope); } $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 ( @@ -422,7 +537,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; } @@ -431,33 +546,104 @@ 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 { + $calleeType = $classResult !== null + ? $classResult->getTypeOnScope($scope, $scope->nativeTypesPromoted) + : $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $scope); + } + + $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/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index a1b2f4d29b9..1b93aa4f75b 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,15 +12,15 @@ 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\Type\ErrorType; use PHPStan\Type\MixedType; @@ -42,6 +40,7 @@ final class StaticPropertyFetchHandler implements ExprHandler public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -66,6 +65,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex ), ]; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $classResult->hasYield(); @@ -74,6 +74,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $classResult->isAlwaysTerminating(); $scope = $classResult->getScope(); } + $nameResult = null; if (!$expr->name instanceof VarLikeIdentifier) { $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $hasYield || $nameResult->hasYield(); @@ -91,63 +92,70 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - ); - } - - 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(); + containsNullsafe: $classResult !== null && $classResult->containsNullsafe(), + issetabilityDescriptor: IssetabilityDescriptor::property($classResult, fn (MutatingScope $s): ?FoundPropertyReflection => $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $s), $expr), + typeCallback: function (bool $nativeTypesPromoted) use ($expr, $classResult, $nameResult, $nodeScopeResolver, $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 { + $resolvedClassType = $classType ?? $nodeScopeResolver->readTypeOfMaybeStored($expr->class, $beforeScope); + $staticPropertyFetchedOnType = TypeCombinator::removeNull($resolvedClassType)->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(); - } + return $propertyReflection->getNativeType(); + } - $fetchType = $this->propertyFetchType( - $scope, - $staticPropertyFetchedOnType, - $expr->name->toString(), - $expr, - ); - if ($fetchType === null) { - $fetchType = new ErrorType(); - } + return $this->propertyFetchType($reflectionScope, $staticPropertyFetchedOnType, $propertyName, $expr) ?? new ErrorType(); + }; - if ($expr->class instanceof Expr) { - return NullsafeShortCircuitingHelper::getType($scope, $expr->class, $fetchType); - } - - 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. + $nameType = $nameResult !== null + ? ($nativeTypesPromoted ? $nameResult->getNativeType() : $nameResult->getType()) + : $nodeScopeResolver->readTypeOfMaybeStored($expr->name, $beforeScope); + 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 @@ -164,9 +172,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/TernaryHandler.php b/src/Analyser/ExprHandler/TernaryHandler.php index 16350c525c1..9526d9e6c0b 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,31 +11,44 @@ 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 WeakMap; use function array_merge; /** * @implements ExprHandler */ #[AutowiredService] -final class TernaryHandler implements ExprHandler +final class TernaryHandler implements ExprHandler, PerFileAnalysisResettable { + /** @var WeakMap */ + private WeakMap $capturedResults; + + public function resetFileAnalysisState(): void + { + $this->capturedResults = new WeakMap(); + } + public function __construct( - private NodeScopeResolver $nodeScopeResolver, private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private BooleanNarrowingHelper $booleanNarrowingHelper, ) { + $this->capturedResults = new WeakMap(); } public function supports(Expr $expr): bool @@ -44,60 +56,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[$expr] ?? null; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult @@ -109,7 +76,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 +92,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 +101,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseScope = $elseResult->getScope(); } + if ($ifResult !== null) { + $this->capturedResults[$expr] = [$ternaryCondResult, $ifResult, $elseResult]; + } + $condType = $ternaryCondResult->getType(); if ($condType->isTrue()->yes()) { $finalScope = $ifTrueScope; @@ -140,7 +114,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 +124,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 +135,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/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/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index 6082dea55f2..3f21b0953a7 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,14 +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; @@ -35,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, + ) { } @@ -44,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(), + ); + $specifiedTypes = $this->identicalNarrowingHelper->specifyIdentical( + $nodeScopeResolver, + $expr->name, + $literalExpr, + $nameResult, + $literalResult, + TypeSpecifierContext::createTruthy(), + $readScope, + $nameArgResult, + null, + fn (): Type => $this->initializerExprTypeResolver->resolveIdenticalType($nameType, $constantString)->type, ); - if ($variableScope->hasVariableType($constantString->getValue())->no()) { - $types[] = new ErrorType(); - continue; + $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 @@ -83,6 +129,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $nameResult = null; if (is_string($expr->name)) { if (in_array($expr->name, Scope::SUPERGLOBAL_VARIABLES, true)) { $impurePoints[] = new ImpurePoint($scope, $expr, 'superglobal', 'access to superglobal variable', true); @@ -95,22 +142,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); $scope = $nameResult->getScope(); } + return $this->expressionResultFactory->create( $scope, - $beforeScope, - $expr, - $hasYield, - $isAlwaysTerminating, - $throwPoints, - $impurePoints, - static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + issetabilityDescriptor: is_string($expr->name) ? IssetabilityDescriptor::variable($expr->name) : null, + 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); - } - } 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 new file mode 100644 index 00000000000..6f35637c634 --- /dev/null +++ b/src/Analyser/ExprHandler/Virtual/IssetExprHandler.php @@ -0,0 +1,64 @@ + + */ +#[AutowiredService] +final class IssetExprHandler implements ExprHandler +{ + + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + + public function supports(Expr $expr): bool + { + return $expr instanceof IssetExpr; + } + + 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 process the inner expr, just report its type + + return $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nodeScopeResolver->readTypeOfMaybeStored($expr->getExpr(), $nativeTypesPromoted ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope), + specifyTypesCallback: fn (TypeSpecifierContext $context, bool $nativeTypesPromoted) => $this->defaultNarrowingHelper->specifyDefaultTypes($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..92bb8f84c70 --- /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->readTypeOfMaybeStored($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); } } 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), + ); } } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 7669a67a2c8..85b3ea0aa1b 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,12 +75,58 @@ public function __construct( private bool $isAlwaysTerminating, private array $throwPoints, private array $impurePoints, - ?callable $truthyScopeCallback = null, - ?callable $falseyScopeCallback = null, + ?callable $typeCallback, + callable $specifyTypesCallback, + private bool $containsNullsafe = false, + private ?IssetabilityDescriptor $issetabilityDescriptor = 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 @@ -57,6 +144,37 @@ public function hasYield(): bool return $this->hasYield; } + /** + * Whether this expression's chain contains a nullsafe operator (?->). A + * fetch/call on a receiver whose chain short-circuits propagates null, + * which a plain nullable receiver (e.g. a nullable variable) does not - + * this flag is what tells them apart. + */ + public function containsNullsafe(): bool + { + return $this->containsNullsafe; + } + + /** + * 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. + */ + public function getIssetabilityResolution(MutatingScope $scope, bool $useNativeTypes): IssetabilityResolution + { + if ($this->issetabilityDescriptor !== null) { + return $this->issetabilityDescriptor->resolve($scope, $useNativeTypes, $this->expr); + } + + $type = $this->getTypeOnScope($scope, $useNativeTypes); + + return new IssetabilityResolution( + IssetabilityLinkInfo::leaf($type, $this->expr, $this->expr instanceof Expr\NullsafePropertyFetch), + null, + ); + } + /** * @return InternalThrowPoint[] */ @@ -79,12 +197,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 @@ -93,12 +218,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 @@ -108,12 +236,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 255d6258fba..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,8 +23,15 @@ public function create( bool $isAlwaysTerminating, array $throwPoints, array $impurePoints, - ?callable $truthyScopeCallback = null, - ?callable $falseyScopeCallback = null, + ?callable $typeCallback, + callable $specifyTypesCallback, + bool $containsNullsafe = false, + ?IssetabilityDescriptor $issetabilityDescriptor = null, + ?MutatingScope $truthyScopeOverride = null, + ?MutatingScope $falseyScopeOverride = null, + ?callable $createTypesCallback = null, + ?Type $type = null, + ?Type $nativeType = null, ): ExpressionResult; } 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/ExpressionTypeHolder.php b/src/Analyser/ExpressionTypeHolder.php index 36f99a85b67..6a8bdb27d7d 100644 --- a/src/Analyser/ExpressionTypeHolder.php +++ b/src/Analyser/ExpressionTypeHolder.php @@ -2,18 +2,33 @@ namespace PHPStan\Analyser; +use PhpParser\Node; use PhpParser\Node\Expr; use PHPStan\TrinaryLogic; use PHPStan\Turbo\ReferencedByTurboExtension; use PHPStan\Turbo\ShadowedByTurboExtension; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_pop; +use function get_class; +use function is_array; #[ShadowedByTurboExtension(turboClass: 'PHPStanTurbo\ExpressionTypeHolder', implementation: __DIR__ . '/../../turbo-ext/src/ExpressionTypeHolder.cpp')] #[ReferencedByTurboExtension(key: 'expressionTypeHolder')] final class ExpressionTypeHolder { + /** + * The node key of every sub-expression, keyed to the classes it appears + * as - what MutatingScope::shouldInvalidateExpression()'s AST scan + * established per invalidation. Holders are shared across scope copies, + * so the one-time subtree scan amortizes over the many invalidation + * checks against the same holder. + * + * @var array, true>>|null + */ + private ?array $containedNodeKeys = null; + public function __construct( private readonly Expr $expr, private readonly Type $type, @@ -22,6 +37,42 @@ public function __construct( { } + /** + * @param callable(Expr): string $keyBuilder + * @return array, true>> + */ + public function getContainedNodeKeys(callable $keyBuilder): array + { + if ($this->containedNodeKeys !== null) { + return $this->containedNodeKeys; + } + + $keys = []; + $stack = [$this->expr]; + while ($stack !== []) { + $node = array_pop($stack); + if ($node instanceof Expr) { + $keys[$keyBuilder($node)][get_class($node)] = true; + } + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->$subNodeName; + if ($subNode instanceof Node) { + $stack[] = $subNode; + } elseif (is_array($subNode)) { + foreach ($subNode as $subNodeItem) { + if (!($subNodeItem instanceof Node)) { + continue; + } + + $stack[] = $subNodeItem; + } + } + } + } + + return $this->containedNodeKeys = $keys; + } + public static function createYes(Expr $expr, Type $type): self { return new self($expr, $type, TrinaryLogic::createYes()); diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index 45989703ec6..6c645ad0356 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -7,20 +7,34 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; +use PHPStan\Analyser\GatheringNodeCallback; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NoopNodeCallback; +use PHPStan\Analyser\ReadVariableStateSnapshot; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Type; +use WeakMap; 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 { + /** + * Last flush-priced answer per asked expression - see processPendingFibers(). + * + * @var WeakMap|null + */ + private ?WeakMap $flushedOnDemandResults = null; + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ @@ -31,6 +45,21 @@ public function callNodeCallback( ExpressionResultStorage $storage, ): void { + // Engine-feeding gatherers must observe the node at the emission + // position - their arrays are read as soon as the enclosing body walk + // 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()); + $nodeCallback = $nodeCallback->getInner(); + } + + if ($nodeCallback instanceof NoopNodeCallback) { + // fibers exist solely to let node callbacks ask about types, + // a noop callback does not need one + return; + } + if (Fiber::getCurrent() !== null) { $nodeCallback($node, $scope->toFiberScope()); return; @@ -52,12 +81,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); } @@ -72,9 +96,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; } @@ -106,23 +130,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(); + $this->flushedOnDemandResults ??= new WeakMap(); + $memoEntry = $this->flushedOnDemandResults[$request->expr] ?? null; + if ($memoEntry !== null && $memoEntry[0]->matches($askScope)) { + $expressionResult = $this->createEagerExpressionResult($askScope, $request->expr, $memoEntry[1], $memoEntry[2]); + } 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[$request->expr] = [ + $expressionResult->takeReadVariableStateSnapshot(), + $expressionResult->getType(), + $expressionResult->getNativeType(), + ]; + } + $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); } @@ -147,17 +224,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 36332a0296e..a097188ac3c 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/GatheringNodeCallback.php b/src/Analyser/GatheringNodeCallback.php new file mode 100644 index 00000000000..6257f53184d --- /dev/null +++ b/src/Analyser/GatheringNodeCallback.php @@ -0,0 +1,52 @@ +gatherer = $gatherer; + $this->inner = $inner; + } + + public function __invoke(Node $node, Scope $scope): void + { + ($this->inner)($node, $scope); + ($this->gatherer)($node, $scope); + } + + /** @return callable(Node, Scope): void */ + public function getGatherer(): callable + { + return $this->gatherer; + } + + /** @return callable(Node, Scope): void */ + public function getInner(): callable + { + return $this->inner; + } + +} diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php new file mode 100644 index 00000000000..3ac913dc4a2 --- /dev/null +++ b/src/Analyser/IssetabilityDescriptor.php @@ -0,0 +1,163 @@ +kind === self::KIND_VARIABLE) { + $variableName = $this->variableName; + if ($variableName === null) { + throw new ShouldNotHappenException(); + } + + $hasVariable = $scope->hasVariableType($variableName); + $valueType = $hasVariable->yes() + ? ($useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain()->getVariableType($variableName) : $scope->getVariableType($variableName)) + : new NeverType(); + + return new IssetabilityResolution(IssetabilityLinkInfo::variable($variableName, $hasVariable, $valueType), null); + } + + if ($this->kind === self::KIND_OFFSET) { + $varResult = $this->varResult; + $dimResult = $this->dimResult; + if ($varResult === null || $dimResult === null) { + throw new ShouldNotHappenException(); + } + + $varType = $varResult->getTypeOnScope($scope, $useNativeTypes); + $dimType = $dimResult->getTypeOnScope($scope, $useNativeTypes); + $hasOffsetValue = $varType->hasOffsetValueType($dimType); + $valueType = $hasOffsetValue->no() ? new NeverType() : $varType->getOffsetValueType($dimType); + + return new IssetabilityResolution( + IssetabilityLinkInfo::offset( + $varType->isOffsetAccessible(), + $hasOffsetValue, + $scope->hasExpressionType($expr)->yes(), + $varType, + $dimType, + $valueType, + ), + $varResult->getIssetabilityResolution($scope, $useNativeTypes), + ); + } + + $reflectionResolver = $this->reflectionResolver; + $propertyFetch = $this->propertyFetch; + if ($reflectionResolver === null || $propertyFetch === null) { + throw new ShouldNotHappenException(); + } + + $inner = $this->innerResult !== null ? $this->innerResult->getIssetabilityResolution($scope, $useNativeTypes) : null; + + $propertyReflection = $reflectionResolver($scope); + if ($propertyReflection === null) { + return new IssetabilityResolution( + IssetabilityLinkInfo::property(null, $propertyFetch, false, false, TrinaryLogic::createNo(), new NeverType(), new NeverType(), false, false, false, false, false, false, false, false), + $inner, + ); + } + + $hasNativeType = $propertyReflection->hasNativeType(); + $nativeReflection = $propertyReflection->getNativeReflection(); + $initializedThisProperty = $propertyFetch instanceof PropertyFetch + && $propertyFetch->name instanceof Identifier + && $propertyFetch->var instanceof Variable + && $propertyFetch->var->name === 'this' + && $scope->hasExpressionType(new PropertyInitializationExpr($propertyReflection->getName()))->yes(); + + return new IssetabilityResolution( + IssetabilityLinkInfo::property( + $propertyReflection, + $propertyFetch, + $propertyReflection->isNative(), + $hasNativeType, + $propertyReflection->isVirtual(), + $propertyReflection->getWritableType(), + $hasNativeType ? $propertyReflection->getNativeType() : new NeverType(), + $scope->hasExpressionType($propertyFetch)->yes(), + isset($scope->getConditionalExpressions()[$scope->getNodeKey($propertyFetch)]), + $initializedThisProperty, + $nativeReflection !== null, + $nativeReflection !== null && $nativeReflection->isPromoted(), + $nativeReflection !== null && $nativeReflection->isReadOnly(), + $nativeReflection !== null && $nativeReflection->isHooked(), + $nativeReflection !== null && $nativeReflection->getNativeReflection()->hasDefaultValue(), + ), + $inner, + ); + } + +} diff --git a/src/Analyser/IssetabilityLinkInfo.php b/src/Analyser/IssetabilityLinkInfo.php new file mode 100644 index 00000000000..a59416d6b99 --- /dev/null +++ b/src/Analyser/IssetabilityLinkInfo.php @@ -0,0 +1,307 @@ +kind === self::KIND_VARIABLE; + } + + public function isOffset(): bool + { + return $this->kind === self::KIND_OFFSET; + } + + public function isProperty(): bool + { + return $this->kind === self::KIND_PROPERTY; + } + + public function getVariableName(): string + { + if ($this->variableName === null) { + throw new ShouldNotHappenException(); + } + + return $this->variableName; + } + + public function getHasVariable(): TrinaryLogic + { + if ($this->hasVariable === null) { + throw new ShouldNotHappenException(); + } + + return $this->hasVariable; + } + + /** The type the operator's callback inspects: variable type, offset value type, property writable type, or leaf type. */ + public function getValueType(): Type + { + if ($this->valueType === null) { + throw new ShouldNotHappenException(); + } + + return $this->valueType; + } + + public function getIsOffsetAccessible(): TrinaryLogic + { + if ($this->isOffsetAccessible === null) { + throw new ShouldNotHappenException(); + } + + return $this->isOffsetAccessible; + } + + public function getHasOffsetValue(): TrinaryLogic + { + if ($this->hasOffsetValue === null) { + throw new ShouldNotHappenException(); + } + + return $this->hasOffsetValue; + } + + public function hasExpressionTypeOfExpr(): bool + { + return $this->hasExpressionTypeOfExpr; + } + + public function getVarType(): Type + { + if ($this->varType === null) { + throw new ShouldNotHappenException(); + } + + return $this->varType; + } + + public function getDimType(): Type + { + if ($this->dimType === null) { + throw new ShouldNotHappenException(); + } + + return $this->dimType; + } + + public function getPropertyReflection(): ?FoundPropertyReflection + { + return $this->propertyReflection; + } + + /** + * @return Expr\PropertyFetch|Expr\StaticPropertyFetch + */ + public function getPropertyFetch(): Expr + { + if (!$this->propertyFetch instanceof Expr\PropertyFetch && !$this->propertyFetch instanceof Expr\StaticPropertyFetch) { + throw new ShouldNotHappenException(); + } + + return $this->propertyFetch; + } + + public function isReflectionNative(): bool + { + return $this->reflectionNative; + } + + public function hasNativeType(): bool + { + return $this->hasNativeType; + } + + public function isVirtual(): TrinaryLogic + { + if ($this->isVirtual === null) { + throw new ShouldNotHappenException(); + } + + return $this->isVirtual; + } + + public function getNativeType(): Type + { + if ($this->nativeType === null) { + throw new ShouldNotHappenException(); + } + + return $this->nativeType; + } + + public function hasExpressionTypeOfFetch(): bool + { + return $this->hasExpressionTypeOfFetch; + } + + /** + * Whether the scope holds conditional-expression entries about the fetch. + * Such entries exist only when the fetch was narrowed in an evaluated + * condition - and evaluating a condition READS the fetch, which would have + * thrown on an uninitialized typed property. A typed-property read + * witnesses initialization. + */ + public function hasConditionalExpressionsOfFetch(): bool + { + return $this->hasConditionalExpressionsOfFetch; + } + + public function isInitializedThisProperty(): bool + { + return $this->initializedThisProperty; + } + + public function nativeReflectionExists(): bool + { + return $this->nativeReflectionExists; + } + + public function nativeIsPromoted(): bool + { + return $this->nativeIsPromoted; + } + + public function nativeIsReadOnly(): bool + { + return $this->nativeIsReadOnly; + } + + public function nativeIsHooked(): bool + { + return $this->nativeIsHooked; + } + + public function nativeHasDefaultValue(): bool + { + return $this->nativeHasDefaultValue; + } + + public function getLeafExpr(): Expr + { + if ($this->leafExpr === null) { + throw new ShouldNotHappenException(); + } + + return $this->leafExpr; + } + + public function leafIsNullsafePropertyFetch(): bool + { + return $this->leafIsNullsafePropertyFetch; + } + +} diff --git a/src/Analyser/IssetabilityResolution.php b/src/Analyser/IssetabilityResolution.php new file mode 100644 index 00000000000..3c41515bfb4 --- /dev/null +++ b/src/Analyser/IssetabilityResolution.php @@ -0,0 +1,178 @@ +link; + } + + public function getInner(): ?IssetabilityResolution + { + return $this->inner; + } + + /** + * Whether isset() of the whole chain holds: null = maybe (resolves to bool), + * true/false = the typeCallback's verdict on the leaf type threaded outward + * over the chain's set-ness. Mirrors the former MutatingScope::issetCheck(). + * + * @param callable(Type): ?bool $typeCallback + */ + public function isSet(callable $typeCallback, ?bool $result = null): ?bool + { + $link = $this->link; + + if ($link->isVariable()) { + $hasVariable = $link->getHasVariable(); + if ($hasVariable->maybe()) { + return null; + } + + if ($result === null) { + if ($hasVariable->yes()) { + if ($link->getVariableName() === '_SESSION') { + return null; + } + + return $typeCallback($link->getValueType()); + } + + return false; + } + + return $result; + } + + if ($link->isOffset()) { + if (!$link->getIsOffsetAccessible()->yes()) { + return $result ?? ($this->inner !== null ? $this->inner->isSetUndefined() : null); + } + + $hasOffsetValue = $link->getHasOffsetValue(); + if ($hasOffsetValue->no()) { + return false; + } + + // If offset cannot be null, store this verdict 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($link->getValueType()); + + if ($result !== null) { + return $this->inner !== null ? $this->inner->isSet($typeCallback, $result) : $result; + } + } + + // Has offset, it is nullable + return null; + } + + if ($link->isProperty()) { + if ($link->getPropertyReflection() === null || !$link->isReflectionNative()) { + return $this->inner !== null ? $this->inner->isSetUndefined() : null; + } + + if ( + $link->hasNativeType() + && !$link->isVirtual()->yes() + && !$link->hasExpressionTypeOfFetch() + && !$link->hasConditionalExpressionsOfFetch() + && !$link->nativeHasDefaultValue() + && (!$link->nativeReflectionExists() || !$link->nativeIsPromoted() || (!$link->nativeIsReadOnly() && !$link->nativeIsHooked())) + ) { + return $this->inner !== null ? $this->inner->isSetUndefined() : null; + } + + if ($result !== null) { + return $this->inner !== null ? $this->inner->isSet($typeCallback, $result) : $result; + } + + $result = $typeCallback($link->getValueType()); + if ($result !== null && $this->inner !== null) { + return $this->inner->isSet($typeCallback, $result); + } + + return $result; + } + + // leaf + return $result ?? $typeCallback($link->getValueType()); + } + + private function isSetUndefined(): ?bool + { + $link = $this->link; + + if ($link->isVariable()) { + if (!$link->getHasVariable()->no()) { + return null; + } + + return false; + } + + if ($link->isOffset()) { + if (!$link->getIsOffsetAccessible()->yes()) { + return $this->inner !== null ? $this->inner->isSetUndefined() : null; + } + + if (!$link->getHasOffsetValue()->no()) { + return $this->inner !== null ? $this->inner->isSetUndefined() : null; + } + + return false; + } + + if ($link->isProperty()) { + return $this->inner !== null ? $this->inner->isSetUndefined() : null; + } + + 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/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/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index e24dc605a84..a64de577f1d 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\ConstFetch; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Match_; @@ -21,6 +22,7 @@ use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Function_; use PhpParser\NodeFinder; +use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\Traverser\TransformStaticTypeTraverser; use PHPStan\Collectors\Collector; use PHPStan\DependencyInjection\Container; @@ -57,6 +59,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; @@ -111,24 +114,29 @@ use function array_map; use function array_merge; use function array_pop; +use function array_shift; use function array_slice; use function array_unique; use function array_values; use function assert; 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; use function strtolower; use function substr; use function uksort; +use function usort; use const PHP_INT_MAX; use const PHP_INT_MIN; use const PHP_VERSION_ID; @@ -136,7 +144,6 @@ class MutatingScope implements Scope, NodeCallbackInvoker, CollectedDataEmitter { - public const KEEP_VOID_ATTRIBUTE_NAME = 'keepVoid'; private const COMPLEX_UNION_TYPE_MEMBER_LIMIT = 8; /** @@ -145,11 +152,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; @@ -183,6 +187,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, @@ -953,9 +958,51 @@ public function getAnonymousFunctionReturnType(): ?Type return $this->anonymousFunctionReflection->getReturnType(); } + /** + * 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 + * swaps it in here so the closure/arrow return-type node and its rules see the + * refined expected return. + */ + public function withAnonymousFunctionReflection(ClosureType $anonymousFunctionReflection): self + { + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + /** @api */ public function getType(Expr $node): Type { + 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(), + )); + } + $type = ScopeOps::getTypeFromCache($this, $node, $key); if ($type !== null) { return $type; @@ -1033,13 +1080,25 @@ public function duplicateWith( ); } - public function getClosureScopeCacheKey(): string + /** + * A cache key of the scope state a closure's type can depend on. With + * $relevantRoots (the closure's free variables, '$this' included) only + * the expression types rooted in them contribute - narrow enough that + * loop-local churn does not invalidate the closure's cached type. Null + * means everything contributes (dynamic variable access in the body). + * + * @param list|null $relevantRoots + */ + public function getClosureScopeCacheKey(?array $relevantRoots = null): string { $parts = []; foreach ($this->expressionTypes as $exprString => $expressionTypeHolder) { if ($expressionTypeHolder->getExpr() instanceof VirtualNode) { continue; } + if ($relevantRoots !== null && !self::exprStringIsRootedIn($exprString, $relevantRoots)) { + continue; + } $parts[] = sprintf('%s::%s', $exprString, $expressionTypeHolder->getType()->describe(VerbosityLevel::cache())); } $parts[] = '---'; @@ -1057,6 +1116,26 @@ public function getClosureScopeCacheKey(): string return md5(implode("\n", $parts)); } + /** @param list $roots */ + private static function exprStringIsRootedIn(string $exprString, array $roots): bool + { + foreach ($roots as $root) { + if ($exprString === $root) { + return true; + } + if (!str_starts_with($exprString, $root)) { + continue; + } + + $next = $exprString[strlen($root)]; + if ($next !== '_' && !ctype_alnum($next)) { + return true; + } + } + + return false; + } + private function resolveType(string $exprString, Expr $node): Type { foreach ($this->expressionTypeResolverExtensions->getAll() as $extension) { @@ -1071,181 +1150,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 */ @@ -1258,6 +1411,8 @@ public function getKeepVoidType(Expr $node): Type { if ( !$node instanceof Match_ + && !$node instanceof Expr\Yield_ + && !$node instanceof Expr\YieldFrom && ( ( !$node instanceof FuncCall @@ -1267,21 +1422,33 @@ 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(): Scope + public function doNotTreatPhpDocTypesAsCertain(): self { return $this->promoteNativeTypes(); } @@ -1388,6 +1555,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 */ @@ -1750,7 +1930,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; @@ -2100,10 +2280,7 @@ public function enterAnonymousFunction( ?array $nativeCallableParameters = null, ): self { - $anonymousFunctionReflection = $this->resolveType('__phpstanClosure', $closure); - if (!$anonymousFunctionReflection instanceof ClosureType) { - throw new ShouldNotHappenException(); - } + $anonymousFunctionReflection = $this->container->getByType(ClosureTypeResolver::class)->getClosureType($this, $closure, true); $scope = $this->enterAnonymousFunctionWithoutReflection($closure, $callableParameters, $nativeCallableParameters); @@ -2291,7 +2468,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(); } @@ -2323,10 +2500,7 @@ private function invalidateStaticExpressions(array $expressionTypes): array */ public function enterArrowFunction(Expr\ArrowFunction $arrowFunction, ?array $callableParameters, ?array $nativeCallableParameters = null): self { - $anonymousFunctionReflection = $this->resolveType('__phpStanArrowFn', $arrowFunction); - if (!$anonymousFunctionReflection instanceof ClosureType) { - throw new ShouldNotHappenException(); - } + $anonymousFunctionReflection = $this->container->getByType(ClosureTypeResolver::class)->getClosureType($this, $arrowFunction, true); $scope = $this->enterArrowFunctionWithoutReflection($arrowFunction, $callableParameters, $nativeCallableParameters); @@ -2542,10 +2716,8 @@ public function enterMatch(Expr\Match_ $expr, Type $condType, Type $condNativeTy return $this->assignExpression($condExpr, $type, $nativeType); } - public function enterForeach(self $originalScope, Expr $iteratee, string $valueName, ?string $keyName, bool $valueByRef): self + public function enterForeach(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $valueName, ?string $keyName, bool $valueByRef): self { - $iterateeType = $originalScope->getType($iteratee); - $nativeIterateeType = $originalScope->getNativeType($iteratee); $valueType = $originalScope->getIterableValueType($iterateeType); $nativeValueType = $originalScope->getIterableValueType($nativeIterateeType); $scope = $this->assignVariable( @@ -2560,9 +2732,13 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN // ($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( - $iteratee, + new NativeTypeExpr($iterateeType, $nativeIterateeType), new NativeTypeExpr( $originalScope->getIterableKeyType($iterateeType), $originalScope->getIterableKeyType($nativeIterateeType), @@ -2574,7 +2750,7 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN ); } if ($keyName !== null) { - $scope = $scope->enterForeachKey($originalScope, $iteratee, $keyName); + $scope = $scope->enterForeachKey($originalScope, $iteratee, $iterateeType, $nativeIterateeType, $keyName); if ($valueByRef && $iterateeType->isArray()->yes() && $iterateeType->isConstantArray()->no()) { $scope = $scope->assignExpression( @@ -2588,11 +2764,8 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN return $scope; } - public function enterForeachKey(self $originalScope, Expr $iteratee, string $keyName): self + public function enterForeachKey(self $originalScope, Expr $iteratee, Type $iterateeType, Type $nativeIterateeType, string $keyName): self { - $iterateeType = $originalScope->getType($iteratee); - $nativeIterateeType = $originalScope->getNativeType($iteratee); - $keyType = $originalScope->getIterableKeyType($iterateeType); $nativeKeyType = $originalScope->getIterableKeyType($nativeIterateeType); @@ -2655,8 +2828,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; } @@ -2686,8 +2857,6 @@ public function exitExpressionAssign(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2747,8 +2916,6 @@ public function setAllowedUndefinedExpression(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2778,8 +2945,6 @@ public function unsetAllowedUndefinedExpression(Expr $expr): self $this->nativeTypesPromoted, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; return $scope; } @@ -2847,8 +3012,13 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } } - $assignedType = $scope->getType($assignedExpr); - $assignedNativeType = $scope->getNativeType($assignedExpr); + // Resolve the byref slot's new value directly from the just-assigned + // root variable's type, instead of re-evaluating the (stale) $assignedExpr + // node via Scope::getType(): the stored ArrayDimFetch result captured the + // array variable before it existed, so re-reading it would only resolve + // through the asking scope. We already hold the authoritative value here. + $assignedType = $this->resolveIntertwinedAssignedType($scope, $type, $assignedExpr, $variableName, false); + $assignedNativeType = $this->resolveIntertwinedAssignedType($scope, $nativeType, $assignedExpr, $variableName, true); $has = $scope->hasExpressionType($expressionType->getExpr()->getExpr()); if ( @@ -2862,14 +3032,15 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp } if ($unionWithOld) { $targetVarNode = new Variable($targetVarName); + $rootVarNode = new Variable($variableName); $assignedType = TypeCombinator::union( $assignedType, - $this->getType($assignedExpr), + $this->resolveIntertwinedAssignedType($this, $this->getType($rootVarNode), $assignedExpr, $variableName, false), $scope->getType($targetVarNode), ); $assignedNativeType = TypeCombinator::union( $assignedNativeType, - $this->getNativeType($assignedExpr), + $this->resolveIntertwinedAssignedType($this, $this->getNativeType($rootVarNode), $assignedExpr, $variableName, true), $scope->getNativeType($targetVarNode), ); } @@ -2896,6 +3067,37 @@ public function assignVariable(string $variableName, Type $type, Type $nativeTyp return $scope; } + /** + * Resolves the type of a byref slot expression (rooted at $rootVariableName) + * from $rootType - the type just assigned to that root variable - by walking + * the offsets, without re-evaluating the stored $assignedExpr node via + * Scope::getType(). + */ + private function resolveIntertwinedAssignedType(self $scope, Type $rootType, Expr $assignedExpr, string $rootVariableName, bool $native): Type + { + if ($assignedExpr instanceof Variable && is_string($assignedExpr->name) && $assignedExpr->name === $rootVariableName) { + return $rootType; + } + + if ($assignedExpr instanceof Expr\ArrayDimFetch && $assignedExpr->dim !== null) { + return $this->resolveIntertwinedAssignedType($scope, $rootType, $assignedExpr->var, $rootVariableName, $native) + ->getOffsetValueType($scope->getType($assignedExpr->dim)); + } + + if ($assignedExpr instanceof SetExistingOffsetValueTypeExpr) { + // foreach-byref slot: the iteratee with its key offset set to the value + // variable's new type ($rootType is exactly that value - the expr's + // getValue()). + $iterateeType = $native + ? $scope->getNativeType($assignedExpr->getVar()) + : $scope->getType($assignedExpr->getVar()); + + return $iterateeType->setExistingOffsetValueType($scope->getType($assignedExpr->getDim()), $rootType); + } + + throw new ShouldNotHappenException(); + } + private function isDimFetchPathReachable(self $scope, Expr\ArrayDimFetch $dimFetch): bool { if ($dimFetch->dim === null) { @@ -2920,10 +3122,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( @@ -2941,11 +3143,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), ), ); } @@ -2954,16 +3156,166 @@ 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 ($expr instanceof Scalar) { + if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { return $this; } + $scope = $this->openSpecificationScope(); + $scope->specifyExpressionTypeInPlace($expr, $type, $nativeType, $certainty); + + return $scope; + } + + /** An unpublished copy of this scope that in-place specification may mutate. */ + private function openSpecificationScope(): self + { + 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->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + private function isSpecifyExpressionTypeNoop(Expr $expr, Type $type): bool + { + if ($expr instanceof Scalar) { + return true; + } + if ($expr instanceof ConstFetch) { $loweredConstName = strtolower($expr->name->toString()); if (in_array($loweredConstName, ['true', 'false', 'null'], true)) { - return $this; + return true; } } @@ -2974,11 +3326,25 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, 'is_file', 'file_exists', ], true)) { - return $this; + return true; } } - $scope = $this; + return false; + } + + /** + * The body of specifyExpressionType() writing straight into this scope's + * holder maps - only to be called on an unpublished scope (see + * openSpecificationScope()). Batching callers avoid one whole-map copy and + * scope construction per specification (and per array-dim level). + */ + private function specifyExpressionTypeInPlace(Expr $expr, Type $type, Type $nativeType, TrinaryLogic $certainty): void + { + if ($this->isSpecifyExpressionTypeNoop($expr, $type)) { + return; + } + if ( $expr instanceof Expr\ArrayDimFetch && $expr->dim !== null @@ -2987,9 +3353,9 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, && !$expr->dim instanceof Expr\PostDec && !$expr->dim instanceof Expr\PostInc ) { - $dimType = $scope->getType($expr->dim)->toArrayKey(); + $dimType = $this->getScopeStateType($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { - $exprVarType = $scope->getType($expr->var); + $exprVarType = $this->getScopeStateType($expr->var); $isArray = $exprVarType->isArray(); if (!$exprVarType instanceof MixedType && !$isArray->no()) { $varType = $exprVarType; @@ -3010,10 +3376,10 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } } - $scope = $scope->specifyExpressionType( + $this->specifyExpressionTypeInPlace( $expr->var, $varType, - $scope->getNativeType($expr->var), + $this->getScopeStateNativeType($expr->var), $certainty, ); } @@ -3025,29 +3391,14 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, } $exprString = $this->getNodeKey($expr); - $expressionTypes = $scope->expressionTypes; - $expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); - $nativeTypes = $scope->nativeExpressionTypes; - $nativeTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); - - /** @var static $scope */ - $scope = ScopeOps::scopeWith( - $this, - $expressionTypes, - $nativeTypes, - $this->conditionalExpressions, - $this->currentlyAssignedExpressions, - $this->currentlyAllowedUndefinedExpressions, - $this->inFunctionCallsStack, - $this->inFirstLevelStatement, - $this->afterExtractCall, - ); + $this->expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); + $this->nativeExpressionTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); - if ($expr instanceof AlwaysRememberedExpr) { - return $scope->specifyExpressionType($expr->expr, $type, $nativeType, $certainty); + if (!($expr instanceof AlwaysRememberedExpr)) { + return; } - return $scope; + $this->specifyExpressionTypeInPlace($expr->expr, $type, $nativeType, $certainty); } public function assignExpression(Expr $expr, Type $type, Type $nativeType): self @@ -3177,18 +3528,28 @@ private function invalidateMethodsOnExpression(Expr $expressionToInvalidate): se ); } - private function setExpressionCertainty(Expr $expr, TrinaryLogic $certainty): self + /** + * Certainty change for applySpecifiedTypes(): + * it keeps the type already held for the expression instead of re-reading it + * via getType(). getType() only reports the type of Yes-certainty holders, so + * for a maybe-defined variable it broadens to the original type - which would + * overwrite a co-applied narrowing (e.g. isset's $a -> null in the else branch). + */ + private function setExpressionCertaintyKeepingType(Expr $expr, TrinaryLogic $certainty): self { - if ($this->hasExpressionType($expr)->no()) { + $exprString = $this->getNodeKey($expr); + if (!array_key_exists($exprString, $this->expressionTypes)) { throw new ShouldNotHappenException(); } - $originalExprType = $this->getType($expr); - $nativeType = $this->getNativeType($expr); + $exprType = $this->expressionTypes[$exprString]->getType(); + $nativeType = array_key_exists($exprString, $this->nativeExpressionTypes) + ? $this->nativeExpressionTypes[$exprString]->getType() + : $exprType; return $this->specifyExpressionType( $expr, - $originalExprType, + $exprType, $nativeType, $certainty, ); @@ -3224,12 +3585,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); @@ -3250,7 +3611,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; } @@ -3262,7 +3623,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(), ); } @@ -3272,16 +3633,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->filterBySpecifiedTypes($specifiedTypes); - $this->truthyScopes[$exprString] = $scope; - return $scope; + return $this->applySpecifiedTypes($specifiedTypes); } /** @@ -3289,58 +3643,106 @@ 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->filterBySpecifiedTypes($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 filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self - { - $typeSpecifications = ScopeOps::buildTypeSpecifications($specifiedTypes->getSureTypes(), $specifiedTypes->getSureNotTypes()); + public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self + { + // deferred augments see this scope's pre-application state - the + // application point of the narrowing; their entries join this batch + $pendingAugments = $specifiedTypes->getDeferredAugments(); + while ($pendingAugments !== []) { + $augment = array_shift($pendingAugments); + $augmentTypes = $augment->evaluate($this); + if ($augmentTypes === null) { + continue; + } - foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$alternativeExpr, $terms]) { - if ( - $alternativeExpr instanceof Node\Scalar - || $alternativeExpr instanceof Expr\Array_ - || ($alternativeExpr instanceof Expr\UnaryMinus && $alternativeExpr->expr instanceof Node\Scalar) - ) { + foreach ($augmentTypes->getDeferredAugments() as $nestedAugment) { + $pendingAugments[] = $nestedAugment; + } + $specifiedTypes = $specifiedTypes->unionWith($augmentTypes); + } + + $typeSpecifications = []; + foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { continue; } $typeSpecifications[] = [ 'sure' => true, 'exprString' => (string) $exprString, - 'expr' => $alternativeExpr, + 'expr' => $expr, + 'type' => $type, + ]; + } + foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $type]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => false, + 'exprString' => (string) $exprString, + 'expr' => $expr, + 'type' => $type, + ]; + } + foreach ($specifiedTypes->getAlternativeTypes() as $exprString => [$expr, $terms]) { + if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { + continue; + } + $typeSpecifications[] = [ + 'sure' => true, + 'exprString' => (string) $exprString, + 'expr' => $expr, 'terms' => $terms, ]; } + usort($typeSpecifications, static function (array $a, array $b): int { + $length = strlen($a['exprString']) - strlen($b['exprString']); + if ($length !== 0) { + return $length; + } + + return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric + }); + $scope = $this; + // one unpublished working copy takes all in-place specifications of the + // batch; operations that go through other scope derivations publish it + // and a fresh copy opens on the next specification + $scopeIsWorkingCopy = false; $specifiedExpressions = []; foreach ($typeSpecifications as $typeSpecification) { $expr = $typeSpecification['expr']; + $exprString = $typeSpecification['exprString']; if ($expr instanceof IssetExpr) { $issetExpr = $expr; $expr = $issetExpr->getExpr(); if ($typeSpecification['sure']) { - $scope = $scope->setExpressionCertainty( + $scope = $scope->setExpressionCertaintyKeepingType( $expr, TrinaryLogic::createMaybe(), ); } else { $scope = $scope->unsetExpression($expr); } + $scopeIsWorkingCopy = false; continue; } @@ -3356,32 +3758,75 @@ public function filterBySpecifiedTypes(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); - $scope = $scope->specifyExpressionType( - $expr, - TypeCombinator::intersect($evaluate($originalExprType), $originalExprType), - TypeCombinator::intersect($evaluate($nativeType), $nativeType), - TrinaryLogic::createYes(), - ); - $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); + $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; + } + $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; } @@ -3389,26 +3834,91 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self if ($typeSpecification['sure']) { if ($specifiedTypes->shouldOverwrite()) { $scope = $scope->assignExpression($expr, $type, $type); + $scopeIsWorkingCopy = false; } else { - $scope = $scope->addTypeToExpression($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(); + $scopeIsWorkingCopy = true; + } + $scope->specifyExpressionTypeInPlace($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } } } else { - $scope = $scope->removeTypeFromExpression($expr, $type); + 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); } - [$conditions] = ScopeOps::matchConditionalExpressions($scope->conditionalExpressions, $specifiedExpressions); + $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); + + $newConditionalExpressionHolders = $specifiedTypes->getNewConditionalExpressionHolders(); + 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 $exprString => $recipeHolders) { + foreach ($recipeHolders as $key => $holder) { + $newConditionalExpressionHolders[$exprString][$key] = $holder; + } + } + } - return $this->applyFilteredConditions($scope, $conditions, $specifiedTypes); + /** @var static */ + 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->afterExtractCall, + $scope->parentScope, + $scope->nativeTypesPromoted, + ); } /** - * @param array $conditions - * @return static + * Matches already-registered conditional expressions against the just-specified + * expression type holders and applies the matching consequences. + * + * Mutates and returns $this - only to be called on an intermediate scope + * that is about to be rebuilt through the scope factory. + * + * @param array $specifiedExpressions */ - private function applyFilteredConditions(self $scope, array $conditions, SpecifiedTypes $specifiedTypes): self + private function processConditionalExpressionsAfterSpecifying(array $specifiedExpressions): self { + $scope = $this; + [$conditions] = ScopeOps::matchConditionalExpressions($scope->conditionalExpressions, $specifiedExpressions); + foreach ($conditions as $conditionalExprString => $expressions) { $certainty = TrinaryLogic::lazyExtremeIdentity($expressions, static fn (ConditionalExpressionHolder $holder) => $holder->getTypeHolder()->getCertainty()); if ($certainty->no()) { @@ -3431,18 +3941,7 @@ private function applyFilteredConditions(self $scope, array $conditions, Specifi } } - /** @var static */ - return ScopeOps::scopeWith( - $scope, - $scope->expressionTypes, - $scope->nativeExpressionTypes, - $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), - $scope->currentlyAssignedExpressions, - $scope->currentlyAllowedUndefinedExpressions, - $scope->inFunctionCallsStack, - $scope->inFirstLevelStatement, - $scope->afterExtractCall, - ); + return $scope; } /** @@ -3509,8 +4008,6 @@ public function exitFirstLevelStatements(): self $this->afterExtractCall, ); $scope->resolvedTypes = $this->resolvedTypes; - $scope->truthyScopes = $this->truthyScopes; - $scope->falseyScopes = $this->falseyScopes; $this->scopeOutOfFirstLevelStatement = $scope; return $scope; @@ -3893,7 +4390,7 @@ private function generalizeVariableTypeHolders( $newVariableTypeHolders = []; foreach ($variableTypeHolders as $variableExprString => $variableTypeHolder) { foreach ($generalizedExpressions as $generalizedExprString => $generalizedExpr) { - if (!ScopeOps::shouldInvalidateExpression($this, $this->exprPrinter, $generalizedExprString, $generalizedExpr, $variableTypeHolder->getExpr(), $variableExprString)) { + if (!ScopeOps::shouldInvalidateExpression($this, $this->exprPrinter, $generalizedExprString, $generalizedExpr, $variableTypeHolder, $variableExprString)) { continue; } @@ -4577,7 +5074,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; } @@ -4592,7 +5090,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 0bef339bebe..6e0e592919a 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -51,7 +51,10 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; 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\VirtualExprResultHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; use PHPStan\BetterReflection\Reflector\Reflector; @@ -85,6 +88,7 @@ use PHPStan\Node\Expr\UnsetOffsetExpr; use PHPStan\Node\FinallyExitPointsNode; use PHPStan\Node\FunctionCallableNode; +use PHPStan\Node\FunctionCallExpressionNode; use PHPStan\Node\FunctionReturnStatementsNode; use PHPStan\Node\InArrowFunctionNode; use PHPStan\Node\InClassMethodNode; @@ -97,6 +101,7 @@ use PHPStan\Node\InTraitNode; use PHPStan\Node\InvalidateExprNode; use PHPStan\Node\MethodCallableNode; +use PHPStan\Node\MethodCallExpressionNode; use PHPStan\Node\MethodReturnStatementsNode; use PHPStan\Node\NoopExpressionNode; use PHPStan\Node\PropertyAssignNode; @@ -104,6 +109,7 @@ use PHPStan\Node\PropertyHookStatementNode; use PHPStan\Node\ReturnStatement; use PHPStan\Node\StaticMethodCallableNode; +use PHPStan\Node\StaticMethodCallExpressionNode; use PHPStan\Node\UnreachableStatementNode; use PHPStan\Node\VariableAssignNode; use PHPStan\Node\VarTagChangedExpressionTypeNode; @@ -140,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; @@ -182,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; @@ -211,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 @@ -240,7 +299,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)] @@ -268,9 +326,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'; } /** @@ -282,6 +341,22 @@ public function setAnalysedFiles(array $files): void $this->analysedFiles = array_fill_keys($files, true); } + /** + * Releases the previous file's node-keyed captures: the parser cache + * retains ASTs, so WeakMap entries keyed by their nodes never die on + * their own and would hold that file's whole result graph alive. + */ + public function resetPerFileAnalysisState(): void + { + foreach ($this->container->getServicesByTag(PerFileAnalysisResettable::TAG) as $resettableService) { + if (!$resettableService instanceof PerFileAnalysisResettable) { + continue; + } + + $resettableService->resetFileAnalysisState(); + } + } + /** * @api * @param Node[] $nodes @@ -293,7 +368,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 = []; @@ -371,14 +475,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 @@ -395,11 +545,19 @@ private function resolveBackwardGotoScope( { $bodyScope = $scope; $count = 0; + $prevEntryScope = null; do { $prevScope = $bodyScope; if ($mergeBodyScopeEachIteration) { $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 + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; $tempStorage = $storage->duplicate(); $bodyScopeResult = $this->processStmtNodesInternal( $parentNode, @@ -509,14 +667,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(); + } } /** @@ -540,7 +703,22 @@ 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; } @@ -557,6 +735,38 @@ private function processStmtNodesInternalWithoutFlushingPendingFibers( callable $nodeCallback, StatementContext $context, ): InternalStatementResult + { + // 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(); + } + } + } + + /** + * @param Node\Stmt[] $stmts + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function doProcessStmtNodes( + Node $parentNode, + array $stmts, + MutatingScope $scope, + ExpressionResultStorage $storage, + callable $nodeCallback, + StatementContext $context, + ): InternalStatementResult { $exitPoints = []; $throwPoints = []; @@ -827,49 +1037,57 @@ public function processStmtNode( $gatheredYieldStatements = []; $executionEnds = []; $functionImpurePoints = []; - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $functionScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $functionScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$functionImpurePoints): void { - $nodeCallback($node, $scope); - 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); - }, 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 @@ -982,62 +1200,73 @@ public function processStmtNode( $gatheredYieldStatements = []; $executionEnds = []; $methodImpurePoints = []; - $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { - $nodeCallback($node, $scope); - 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->readTypeOfMaybeStored($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); - }, 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); + } finally { + $scope->popExpressionResultStorage(); + } if ($isConstructor) { $finalScope = null; @@ -1083,7 +1312,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($this, $echoExpr, $scope, $result); $throwPoints = array_merge($throwPoints, $toStringResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $toStringResult->getImpurePoints()); $scope = $result->getScope(); @@ -1142,19 +1371,19 @@ public function processStmtNode( if ($stmt->expr instanceof Expr\Throw_) { $scope = $stmtScope; } - $earlyTerminationExpr = $this->findEarlyTerminatingExpr($stmt->expr, $scope); $hasAssign = false; $currentScope = $scope; - $result = $this->processExprNode($stmt, $stmt->expr, $scope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $currentScope, &$hasAssign): void { + $result = $this->processExprNode($stmt, $stmt->expr, $scope, $storage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($currentScope, &$hasAssign): void { if ( - ($node instanceof VariableAssignNode || $node instanceof PropertyAssignNode) - && $scope->getAnonymousFunctionReflection() === $currentScope->getAnonymousFunctionReflection() - && $scope->getFunction() === $currentScope->getFunction() + !($node instanceof VariableAssignNode) && !($node instanceof PropertyAssignNode) + || $scope->getAnonymousFunctionReflection() !== $currentScope->getAnonymousFunctionReflection() + || $scope->getFunction() !== $currentScope->getFunction() ) { - $hasAssign = true; + return; } - $nodeCallback($node, $scope); - }, ExpressionContext::createTopLevel()); + + $hasAssign = true; + }, $nodeCallback), ExpressionContext::createTopLevel()); $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); if ( count($result->getImpurePoints()) === 0 @@ -1167,17 +1396,19 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, new NoopExpressionNode($stmt->expr, $hasAssign), $scope, $storage); } $scope = $result->getScope(); - $scope = $scope->filterBySpecifiedTypes($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(); $isAlwaysTerminating = $result->isAlwaysTerminating(); - if ($earlyTerminationExpr !== null) { + // 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). + $statementType = $result->getType(); + if ($statementType instanceof NeverType && $statementType->isExplicit()) { return new InternalStatementResult($scope, $hasYield, true, [ new InternalStatementExitPoint($stmt, $scope), ], $overridingThrowPoints ?? $throwPoints, $impurePoints); @@ -1456,10 +1687,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); @@ -1476,54 +1717,121 @@ public function processStmtNode( $originalScope = $scope; $bodyScope = $scope; + $foreachIterateeType = $condResult->getType(); + $foreachNativeIterateeType = $condResult->getNativeType(); + if ($stmt->keyVar instanceof Variable) { $keyTypeExpr = new NativeTypeExpr( - $originalScope->getIterableKeyType($originalScope->getType($stmt->expr)), - $originalScope->getIterableKeyType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableKeyType($foreachIterateeType), + $originalScope->getIterableKeyType($foreachNativeIterateeType), ); $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->keyVar, $keyTypeExpr), $originalScope, $storage); } if ($stmt->valueVar instanceof Variable) { $valueTypeExpr = new NativeTypeExpr( - $originalScope->getIterableValueType($originalScope->getType($stmt->expr)), - $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableValueType($foreachIterateeType), + $originalScope->getIterableValueType($foreachNativeIterateeType), ); $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->valueVar, $valueTypeExpr), $originalScope, $storage); } elseif ($stmt->valueVar instanceof List_) { $virtualAssign = new Assign($stmt->valueVar, new NativeTypeExpr( - $originalScope->getIterableValueType($originalScope->getType($stmt->expr)), - $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableValueType($foreachIterateeType), + $originalScope->getIterableValueType($foreachNativeIterateeType), )); $virtualAssign->setAttributes($stmt->valueVar->getAttributes()); $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; - $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); + $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, $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 + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $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; @@ -1537,9 +1845,9 @@ public function processStmtNode( } } - $bodyScope = $bodyScope->mergeWith($iterateeScope); + $bodyScope = $bodyScope->mergeWith($nonEmptyIterateeScope); $storage = $originalStorage; - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context; $finalScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints(); $finalScope = $finalScopeResult->getScope(); @@ -1589,7 +1897,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 @@ -1607,8 +1926,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->readTypeOfMaybeStored($arrayExprDimFetch, $scopeWithIterableValueType); + $dimFetchNativeType = $this->readTypeOfMaybeStored($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 @@ -1617,23 +1938,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->readTypeOfMaybeStored($keyVarExpr, $scopeWithIterableValueType); + $keyLoopNativeTypes[] = $this->readTypeOfMaybeStored($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; @@ -1645,7 +1971,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); @@ -1661,7 +1987,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); @@ -1689,7 +2015,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_([]), @@ -1697,7 +2023,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) { @@ -1709,7 +2035,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; } @@ -1728,36 +2054,57 @@ 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; + $prevEntryScope = null; do { $prevScope = $bodyScope; $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 + $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; @@ -1773,14 +2120,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(); } @@ -1841,21 +2195,35 @@ public function processStmtNode( $originalStorage = $storage; if ($context->isTopLevel()) { + $prevEntryScope = null; do { $prevScope = $bodyScope; $bodyScope = $bodyScope->mergeWith($scope); - $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()); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit (and repeats only idempotent + // merges into the final scope), so the verification walk is skipped + $bodyScope = $prevScope; + break; } - $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); - foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { - $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + $prevEntryScope = $bodyScope; + $storage = $originalStorage->duplicate(); + $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; } @@ -1876,9 +2244,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(); } @@ -1894,13 +2269,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); @@ -1940,46 +2312,63 @@ 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(); } } if ($context->isTopLevel()) { $count = 0; + $prevEntryScope = null; do { $prevScope = $bodyScope; $storage = $originalStorage->duplicate(); $bodyScope = $bodyScope->mergeWith($initScope); - 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()); + 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 + $bodyScope = $prevScope; + break; } + $prevEntryScope = $bodyScope; + $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)) { @@ -1998,9 +2387,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(); @@ -2016,7 +2410,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); @@ -2085,7 +2479,23 @@ public function processStmtNode( $hasYield = $hasYield || $caseResult->hasYield(); $throwPoints = array_merge($throwPoints, $caseResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $caseResult->getImpurePoints()); - $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; @@ -2111,7 +2521,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()) { @@ -2122,7 +2532,13 @@ public function processStmtNode( } } - $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; @@ -2375,7 +2791,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->processExprNode( $stmt, @@ -2405,7 +2821,22 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch /** @var Expr $clonedVar */ [$clonedVar] = $traverser->traverse([$clonedVar]); - $scope = $this->processVirtualAssign($scope, $storage, $stmt, $clonedVar, new UnsetOffsetExpr($var->var, $var->dim), $nodeCallback)->getScope(); + $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( @@ -2742,18 +3173,156 @@ private function lookForExpressionCallback(MutatingScope $scope, Expr $expr, Clo return $scope; } - private function findEarlyTerminatingExpr(Expr $expr, Scope $scope): ?Expr + /** + * 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 { - if ($expr instanceof Expr\Exit_ || $expr instanceof Expr\Throw_) { - return $expr; + $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 = false; } + } + + public function findStoredResult(Expr $expr, MutatingScope $scope): ?ExpressionResult + { + $storage = $scope->getCurrentExpressionResultStorage(); - $exprType = $scope->getType($expr); - if ($exprType instanceof NeverType && $exprType->isExplicit()) { - return $expr; + return $storage !== null ? $storage->findExpressionResult($expr) : null; + } + + /** + * 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 null; + 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 + { + $result = $this->findStoredResult($expr, $scope); + if ($result !== null) { + return $result->getTypeOnScope($scope, $scope->nativeTypesPromoted); + } + + // a plain variable read is scope state - answer it directly instead of + // processing the node on demand (mirrors VariableHandler's typeCallback) + if ($expr instanceof Expr\Variable && is_string($expr->name)) { + if ($scope->hasVariableType($expr->name)->no()) { + return new ErrorType(); + } + + return $scope->getVariableType($expr->name); + } + + // a type tracked for the whole expression answers without a walk - the + // on-demand processing below would return this very holder anyway (the + // fresh result's beforeScope is the asking scope), after paying the walk + if ( + !$expr instanceof Expr\Variable + && !$expr instanceof Expr\Closure + && !$expr instanceof Expr\ArrowFunction + && $scope->hasExpressionType($expr)->yes() + ) { + return $scope->getTrackedExpressionType($expr); + } + + return $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()); } /** @@ -2767,6 +3336,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) { @@ -2790,6 +3419,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; @@ -2801,21 +3434,20 @@ public function processExprNode( if ($exprHandler !== null) { $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 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, $expressionResult), $scope, $storage, $context); + } elseif ($expr instanceof MethodCall) { + $this->callNodeCallbackWithExpression($nodeCallback, new MethodCallExpressionNode($expr, $expressionResult), $scope, $storage, $context); + } elseif ($expr instanceof StaticCall) { + $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))); } /** @@ -2947,6 +3579,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); @@ -2970,7 +3633,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 { @@ -2981,7 +3644,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 { @@ -3018,11 +3681,12 @@ public function processClosureNode( $executionEnds = []; $gatheredReturnStatements = []; + $gatheredReturnStatementsWithScope = []; $gatheredYieldStatements = []; + $gatheredYieldStatementsWithScope = []; $closureImpurePoints = []; $invalidateExpressions = []; - $closureStmtsCallback = static function (Node $node, Scope $scope) use ($nodeCallback, &$executionEnds, &$gatheredReturnStatements, &$gatheredYieldStatements, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { - $nodeCallback($node, $scope); + $closureStmtsCallback = new GatheringNodeCallback(static function (Node $node, Scope $scope) use (&$executionEnds, &$gatheredReturnStatements, &$gatheredReturnStatementsWithScope, &$gatheredYieldStatements, &$gatheredYieldStatementsWithScope, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { return; } @@ -3047,17 +3711,20 @@ public function processClosureNode( } if ($node instanceof Expr\Yield_ || $node instanceof Expr\YieldFrom) { $gatheredYieldStatements[] = $node; + $gatheredYieldStatementsWithScope[] = [$node, $scope]; } if (!$node instanceof Return_) { return; } $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }; + $gatheredReturnStatementsWithScope[] = [$node, $scope]; + }, $nodeCallback); if (count($byRefUses) === 0) { $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); $publicStatementResult = $statementResult->toPublic(); + $closureReturnStatementsNodeScope = $this->refineClosureNodeScope($closureScope, $scope, $expr, $gatheredReturnStatementsWithScope, $gatheredYieldStatementsWithScope, $executionEnds, $statementResult->getThrowPoints(), array_merge($closureImpurePoints, $statementResult->getImpurePoints()), $invalidateExpressions); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( $expr, $gatheredReturnStatements, @@ -3065,9 +3732,18 @@ public function processClosureNode( $publicStatementResult, $executionEnds, array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), - ), $closureScope, $storage); + ), $closureReturnStatementsNodeScope, $storage); - return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions); + return new ProcessClosureResult( + $scope, + $statementResult->getThrowPoints(), + $statementResult->getImpurePoints(), + $invalidateExpressions, + $gatheredReturnStatementsWithScope, + $gatheredYieldStatementsWithScope, + $executionEnds, + array_merge($closureImpurePoints, $statementResult->getImpurePoints()), + ); } $originalStorage = $storage; @@ -3078,7 +3754,11 @@ public function processClosureNode( $prevScope = $closureScope; $storage = $originalStorage->duplicate(); - $intermediaryClosureScopeResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createTopLevel()); + // deep context, like the loop handlers' own convergence passes: inner + // loops walk single-pass here and only the final walk below (top-level) + // runs their full convergence - otherwise every closure-convergence + // pass would re-converge every inner loop from scratch + $intermediaryClosureScopeResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createDeep()); $intermediaryClosureScope = $intermediaryClosureScopeResult->getScope(); foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) { $intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope()); @@ -3108,6 +3788,7 @@ public function processClosureNode( $storage = $originalStorage; $statementResult = $this->processStmtNodesInternalWithoutFlushingPendingFibers($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel()); $publicStatementResult = $statementResult->toPublic(); + $closureReturnStatementsNodeScope = $this->refineClosureNodeScope($closureScope, $scope, $expr, $gatheredReturnStatementsWithScope, $gatheredYieldStatementsWithScope, $executionEnds, $statementResult->getThrowPoints(), array_merge($closureImpurePoints, $statementResult->getImpurePoints()), $invalidateExpressions); $this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode( $expr, $gatheredReturnStatements, @@ -3115,9 +3796,61 @@ public function processClosureNode( $publicStatementResult, $executionEnds, array_merge($publicStatementResult->getImpurePoints(), $closureImpurePoints), - ), $closureScope, $storage); + ), $closureReturnStatementsNodeScope, $storage); + + return new ProcessClosureResult( + $scope, + $statementResult->getThrowPoints(), + $statementResult->getImpurePoints(), + $invalidateExpressions, + $gatheredReturnStatementsWithScope, + $gatheredYieldStatementsWithScope, + $executionEnds, + array_merge($closureImpurePoints, $statementResult->getImpurePoints()), + $closureResultScope, + $byRefUses, + ); + } + + /** + * 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 $gatheredYieldStatementsWithScope + * @param list $executionEnds + * @param InternalThrowPoint[] $throwPoints + * @param ImpurePoint[] $impurePoints + * @param InvalidateExprNode[] $invalidateExpressions + */ + private function refineClosureNodeScope( + MutatingScope $closureScope, + MutatingScope $scope, + Expr\Closure $expr, + array $gatheredReturnStatementsWithScope, + array $gatheredYieldStatementsWithScope, + array $executionEnds, + array $throwPoints, + array $impurePoints, + array $invalidateExpressions, + ): MutatingScope + { + $refinedClosureType = $this->container->getByType(ClosureTypeResolver::class)->buildClosureTypeForClosure( + $scope, + $expr, + $gatheredReturnStatementsWithScope, + $gatheredYieldStatementsWithScope, + $executionEnds, + $throwPoints, + $impurePoints, + $invalidateExpressions, + ); - return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, $closureResultScope, $byRefUses); + return $closureScope->withAnonymousFunctionReflection($refinedClosureType); } /** @@ -3155,7 +3888,7 @@ public function processArrowFunctionNode( callable $nodeCallback, ?Type $passedToType, ?Type $nativePassedToType = null, - ): ExpressionResult + ): ProcessArrowFunctionResult { foreach ($expr->params as $param) { $this->processParamNode($stmt, $param, $scope, $storage, $nodeCallback); @@ -3168,40 +3901,122 @@ public function processArrowFunctionNode( $callableParameters = $this->createCallableParameters($scope, $expr, $arrowFunctionCallArgs, $passedToType); $nativeCallableParameters = $this->createNativeCallableParameters($scope, $expr, $arrowFunctionCallArgs, $nativePassedToType); $arrowFunctionScope = $scope->enterArrowFunction($expr, $callableParameters, $nativeCallableParameters); - $arrowFunctionType = $arrowFunctionScope->getAnonymousFunctionReflection(); - if ($arrowFunctionType === null) { + if ($arrowFunctionScope->getAnonymousFunctionReflection() === null) { throw new ShouldNotHappenException(); } - $this->callNodeCallback($nodeCallback, new InArrowFunctionNode($arrowFunctionType, $expr), $arrowFunctionScope, $storage); - $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $nodeCallback, ExpressionContext::createTopLevel()); - return $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $expr, hasYield: false, isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints()); + // Gather the property-assign impure points and invalidate expressions the + // arrow function type needs (mirroring ClosureTypeResolver::getClosureType()), + // on top of the regular rule node callback, so the single body walk here + // feeds ClosureTypeResolver::buildClosureTypeForArrowFunction(). + $arrowFunctionImpurePoints = []; + $invalidateExpressions = []; + $arrowFunctionStmtsCallback = new GatheringNodeCallback(static function (Node $node, Scope $innerScope) use ($arrowFunctionScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { + if ($innerScope->getAnonymousFunctionReflection() !== $arrowFunctionScope->getAnonymousFunctionReflection()) { + return; + } + + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } + + if (!$node instanceof PropertyAssignNode) { + return; + } + + $arrowFunctionImpurePoints[] = new ImpurePoint( + $innerScope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + }, $nodeCallback); + + $exprResult = $this->processExprNode($stmt, $expr->expr, $arrowFunctionScope, $storage, $arrowFunctionStmtsCallback, ExpressionContext::createTopLevel()); + + $closureTypeThrowPoints = array_map(static fn (InternalThrowPoint $throwPoint) => $throwPoint->toPublic(), $exprResult->getThrowPoints()); + $closureTypeImpurePoints = array_merge($arrowFunctionImpurePoints, $exprResult->getImpurePoints()); + + // 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 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, + $arrowFunctionScope, + $closureTypeThrowPoints, + $closureTypeImpurePoints, + $invalidateExpressions, + ); + $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(), + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ), + $arrowFunctionScope, + $closureTypeThrowPoints, + $closureTypeImpurePoints, + $invalidateExpressions, + ); } /** * @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, static fn (Scope $s, Expr $e) => $s->getNativeType($e)); + 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 + { + 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) { @@ -3330,15 +4145,14 @@ private function processAttributeGroups( $classReflection = $this->reflectionProvider->getClass($className); if ($classReflection->hasConstructor()) { $constructorReflection = $classReflection->getConstructor(); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, + $parametersAcceptor = ParametersAcceptorSelector::combineVariantsForNormalization( $attr->args, $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants(), ); $expr = new New_($attr->name, $attr->args); $expr = ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $expr) ?? $expr; - $this->processArgs($stmt, $constructorReflection, null, $parametersAcceptor, $expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $this->processArgs($stmt, $constructorReflection, null, $constructorReflection->getVariants(), $constructorReflection->getNamedArgumentsVariants(), $expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $this->callNodeCallback($nodeCallback, $attr, $scope, $storage); continue; } @@ -3433,8 +4247,7 @@ private function processPropertyHooks( $gatheredReturnStatements = []; $executionEnds = []; $methodImpurePoints = []; - $statementResult = $this->processStmtNodesInternal(new PropertyHookStatementNode($hook), $stmts, $hookScope, $storage, static function (Node $node, Scope $scope) use ($nodeCallback, $hookScope, &$gatheredReturnStatements, &$executionEnds, &$hookImpurePoints): void { - $nodeCallback($node, $scope); + $statementResult = $this->processStmtNodesInternal(new PropertyHookStatementNode($hook), $stmts, $hookScope, $storage, new GatheringNodeCallback(static function (Node $node, Scope $scope) use ($hookScope, &$gatheredReturnStatements, &$executionEnds, &$hookImpurePoints): void { if ($scope->getFunction() !== $hookScope->getFunction()) { return; } @@ -3460,7 +4273,7 @@ private function processPropertyHooks( } $gatheredReturnStatements[] = new ReturnStatement($scope, $node); - }, StatementContext::createTopLevel())->toPublic(); + }, $nodeCallback), StatementContext::createTopLevel())->toPublic(); $this->callNodeCallback($nodeCallback, new PropertyHookReturnStatementsNode( $hook, @@ -3526,27 +4339,52 @@ private function resolveClosureThisType( /** * @param MethodReflection|FunctionReflection|null $calleeReflection + * @param ParametersAcceptor[] $parametersAcceptors + * @param ParametersAcceptor[]|null $namedArgumentsVariants * @param callable(Node $node, Scope $scope): void $nodeCallback + * @param (callable(MutatingScope): MutatingScope)|null $closureBindScopeFactory */ public function processArgs( Node\Stmt $stmt, $calleeReflection, ?ExtendedMethodReflection $nakedMethodReflection, - ?ParametersAcceptor $parametersAcceptor, + array $parametersAcceptors, + ?array $namedArgumentsVariants, CallLike $callLike, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context, - ?MutatingScope $closureBindScope = null, - ): ExpressionResult + ?callable $closureBindScopeFactory = null, + ): ArgsResult { $args = $callLike->getArgs(); - $parameters = null; - if ($parametersAcceptor !== null) { - $parameters = $parametersAcceptor->getParameters(); - } + // Evolving-scope arg types: gathered as each argument is processed on the + // scope that evolves arg-to-arg. They select the FINAL resolved acceptor + // (the call's return type, by-ref OUT types), which type-resolves generics + // from the actual argument types. + $gatheredTypes = []; + $gatheredUnpack = false; + $gatheredHasName = false; + $gatheredArgTypeByIndex = []; + + // 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 + // resolves generic parameter types from the args gathered so far; the call's return type + // comes from the post-loop resolved acceptor. + $metadataAcceptor = $parametersAcceptors[0] ?? null; + + // 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 - + // its signature-only shallow type keeps the count/name bookkeeping correct. + $typeDrivenAcceptorSelection = count($parametersAcceptors) > 1 + || $namedArgumentsVariants !== null + || ($metadataAcceptor !== null && ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableType($metadataAcceptor)); $hasYield = false; $throwPoints = []; @@ -3558,33 +4396,95 @@ public function processArgs( $deferredByRefClosureResults = []; $processingOrder = array_keys($args); - $hasReorderedArgs = false; - foreach ($args as $arg) { - if ($arg->hasAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE)) { - $hasReorderedArgs = true; - break; + usort($processingOrder, static function (int $a, int $b) use ($args): int { + $aOriginalArg = $args[$a]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + $bOriginalArg = $args[$b]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + $aValue = $aOriginalArg !== null ? $aOriginalArg->value : $args[$a]->value; + $bValue = $bOriginalArg !== null ? $bOriginalArg->value : $args[$b]->value; + $aIsClosure = $aValue instanceof Expr\Closure || $aValue instanceof Expr\ArrowFunction; + $bIsClosure = $bValue instanceof Expr\Closure || $bValue instanceof Expr\ArrowFunction; + if ($aIsClosure !== $bIsClosure) { + // closures sort after non-closures so every sibling feeding an + // intrinsic override / generic callable(T) is in scope first + return $aIsClosure ? 1 : -1; } - } - if ($hasReorderedArgs) { - usort($processingOrder, static function (int $a, int $b) use ($args): int { - $aOriginal = $args[$a]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); - $bOriginal = $args[$b]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); - if ($aOriginal === null && $bOriginal === null) { - return $a <=> $b; - } - if ($aOriginal === null) { - return 1; - } - if ($bOriginal === null) { - return -1; - } - return $aOriginal->getStartTokenPos() <=> $bOriginal->getStartTokenPos(); - }); - } + $aOriginal = $args[$a]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + $bOriginal = $args[$b]->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE); + if ($aOriginal === null && $bOriginal === null) { + return $a <=> $b; + } + if ($aOriginal === null) { + return 1; + } + if ($bOriginal === null) { + return -1; + } + + return $aOriginal->getStartTokenPos() <=> $bOriginal->getStartTokenPos(); + }); + $argResults = []; + $countStableMetadataAcceptor = null; foreach ($processingOrder as $i) { $arg = $args[$i]; + + if ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction) { + // Gather the closure/arrow type for the FINAL resolved acceptor on + // the evolving scope, BEFORE the body is processed with a possibly + // generic-resolved parameter injected, so the inferred return type + // stays faithful to the closure's own declaration and its own + // contribution (a TValue from its return) participates in the final + // resolution (see gatherClosureArgType()). + $originalArgForGather = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; + $gatheredArgTypeByIndex[$i] = $typeDrivenAcceptorSelection + ? $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope) + : $this->container->getByType(ClosureTypeResolver::class)->getClosureType($scope, $arg->value, true); + $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $gatheredArgTypeByIndex[$i]); + } + + $argMetadataAcceptor = $metadataAcceptor; + 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 + // args that pin them, so determining siblings are already processed; the mixed pad keeps + // the argument COUNT correct so the by-ref/variadic variant stays stable (e.g. sscanf), + // while processed siblings resolve a generic callable(T) parameter. No forward read. + $paddedTypes = []; + $paddedUnpack = false; + $paddedHasName = false; + foreach ($args as $j => $paddedArg) { + $paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg; + $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, $gatheredArgTypeByIndex[$j] ?? new MixedType()); + } + $argMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope); + } else { + // Only a closure/arrow function consumes the generic-RESOLVED + // parameter type: its body is inferred from the resolved + // callable(T) - directly, or through the in-function-call stack + // when nested anywhere inside the argument. Every other argument + // reads variant-stable facts off its parameter (by-ref flag, + // callable bookkeeping), so one all-mixed count-stable selection + // serves them all instead of a full template inference per argument. + if ($countStableMetadataAcceptor === null) { + $paddedTypes = []; + $paddedUnpack = false; + $paddedHasName = false; + foreach ($args as $j => $paddedArg) { + $paddedOriginalArg = $paddedArg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $paddedArg; + $this->addGatheredArgType($paddedTypes, $paddedUnpack, $paddedHasName, $paddedOriginalArg, $j, new MixedType()); + } + $countStableMetadataAcceptor = $this->selectArgsMetadataAcceptor($args, $paddedTypes, $parametersAcceptors, $namedArgumentsVariants, $paddedHasName, $paddedUnpack, $scope); + } + $argMetadataAcceptor = $countStableMetadataAcceptor; + } + } + $parameters = $argMetadataAcceptor !== null ? $argMetadataAcceptor->getParameters() : null; + $assignByReference = false; $parameter = null; $parameterType = null; @@ -3610,7 +4510,7 @@ public function processArgs( $parameterNativeType = $matchedParameter->getNativeType(); } $parameter = $matchedParameter; - } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { + } elseif (count($parameters) > 0 && $argMetadataAcceptor->isVariadic()) { $lastParameter = array_last($parameters); $assignByReference = $lastParameter->passedByReference()->createsNewVariable(); $parameterType = $lastParameter->getType(); @@ -3649,128 +4549,246 @@ public function processArgs( $originalScope = $scope; $scopeToPass = $scope; - if ($i === 0 && $closureBindScope !== null && ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction)) { - $scopeToPass = $closureBindScope; + if ($i === 0 && $closureBindScopeFactory !== null && ($arg->value instanceof Expr\Closure || $arg->value instanceof Expr\ArrowFunction)) { + $scopeToPass = $closureBindScopeFactory($scope); } if ($arg->value instanceof Expr\Closure) { - $restoreThisScope = null; - if ( - $closureBindScope === 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 ($overwritingParameterType !== null) { - $parameterType = $overwritingParameterType; + $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()); + } + } - $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()); - } + if ($parameter !== null) { + $overwritingParameterType = $this->getParameterTypeFromParameterClosureTypeExtension($callLike, $calleeReflection, $parameter, $scopeToPass); - $this->storeExpressionResult($storage, $arg->value, $this->expressionResultFactory->create( - $closureResult->getScope(), - $scopeToPass, - $arg->value, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - )); + if ($overwritingParameterType !== null) { + $parameterType = $overwritingParameterType; + } + } - $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; + } + + $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); } - $invalidateExpressions = array_values($invalidateExpressions); - $scope = $scope->restoreThis($restoreThisScope); - } - if ($this->shouldInvalidateCallbackExpressions($parameter)) { - $deferredInvalidateExpressions[] = [$invalidateExpressions, $uses]; + if ($this->shouldInvalidateCallbackExpressions($parameter)) { + $deferredInvalidateExpressions[] = [$invalidateExpressions, $uses]; + } } } elseif ($arg->value instanceof Expr\ArrowFunction) { - if ( - $closureBindScope === 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; + } } - } - $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); - $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $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), $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(); + $argResults[spl_object_id($arg->value)] = $arrowFunctionExprResult; + 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(), + ); + $this->storeExpressionResult($storage, $arg->value, $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(), + )); + 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(); @@ -3796,6 +4814,9 @@ public function processArgs( } } } + + $gatheredArgTypeByIndex[$i] = $exprResult->getType(); + $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArg, $i, $gatheredArgTypeByIndex[$i]); } if ($assignByReference && $lookForUnset) { @@ -3806,7 +4827,7 @@ public function processArgs( $scope = $scope->popInFunctionCall(); } - if ($i !== 0 || $closureBindScope === null) { + if ($i !== 0 || $closureBindScopeFactory === null) { continue; } @@ -3821,14 +4842,36 @@ public function processArgs( $scope = $deferredClosureResult->applyByRefUseScope($scope); } - if ($parameters !== null) { + // 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(). + $resolvedAcceptor = null; + if ($parametersAcceptors !== []) { + $resolvedAcceptor = $this->selectArgsMetadataAcceptor($args, $gatheredTypes, $parametersAcceptors, $namedArgumentsVariants, $gatheredHasName, $gatheredUnpack, $scope); + } + + // The by-ref OUT writeback reads the metadata acceptor: it is selected from + // the full argument count (stable variant). When that single acceptor still + // carries templates (fast path), its OUT types need generic-resolving from the + // 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 + && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) + ) { + $writebackAcceptor = $resolvedAcceptor; + } + $writebackParameters = $writebackAcceptor !== null ? $writebackAcceptor->getParameters() : null; + if ($writebackParameters !== null) { foreach ($args as $i => $arg) { $assignByReference = false; $currentParameter = null; - if (isset($parameters[$i])) { - $currentParameter = $parameters[$i]; - } elseif (count($parameters) > 0 && $parametersAcceptor->isVariadic()) { - $currentParameter = array_last($parameters); + if (isset($writebackParameters[$i])) { + $currentParameter = $writebackParameters[$i]; + } elseif (count($writebackParameters) > 0 && $writebackAcceptor->isVariadic()) { + $currentParameter = array_last($writebackParameters); } if ($currentParameter !== null) { @@ -3875,15 +4918,16 @@ public function processArgs( $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { - $argType = $scope->getType($arg->value); + $argType = $this->readStoredResult($arg->value, $storage)->getTypeOnScope($scope, false); if (!$argType->isObject()->no()) { $nakedReturnType = null; if ($nakedMethodReflection !== null) { - $nakedParametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $args, + $nakedParametersAcceptor = $this->selectArgsAcceptor( + $gatheredTypes, $nakedMethodReflection->getVariants(), $nakedMethodReflection->getNamedArgumentsVariants(), + $gatheredHasName, + $gatheredUnpack, ); $nakedReturnType = $nakedParametersAcceptor->getReturnType(); } @@ -3904,7 +4948,200 @@ public function processArgs( } // not storing this, it's scope after processing all args - return $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return new ArgsResult( + $this->expressionResultFactory->create( + $scope, + $scope, + $callLike, + $hasYield, + $isAlwaysTerminating, + $throwPoints, + $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: SpecifiedTypes::emptySpecifyCallback(), + ), + $resolvedAcceptor, + $argResults, + ); + } + + /** + * Ports the gather-keying of ParametersAcceptorSelector::selectFromArgs(): + * indexes the gathered arg type by name (sets $hasName) vs position, and + * expands unpacked constant arrays / falls back to the iterable value type + * (sets $unpack), so selectFromTypes() picks the matching variant. + * + * @param array $types + */ + private function addGatheredArgType(array &$types, bool &$unpack, bool &$hasName, Node\Arg $originalArg, int $i, Type $type): void + { + if ($originalArg->name !== null) { + $index = $originalArg->name->toString(); + $hasName = true; + } else { + $index = $i; + } + + if ($originalArg->unpack) { + $unpack = true; + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) > 0) { + foreach ($constantArrays as $constantArray) { + $values = $constantArray->getValueTypes(); + foreach ($constantArray->getKeyTypes() as $j => $keyType) { + $valueType = $values[$j]; + $valueIndex = $keyType->getValue(); + if (is_string($valueIndex)) { + $hasName = true; + } else { + $valueIndex = $i + $j; + } + + $types[$valueIndex] = isset($types[$valueIndex]) + ? TypeCombinator::union($types[$valueIndex], $valueType) + : $valueType; + } + } + } else { + $types[$index] = $type->getIterableValueType(); + } + } else { + $types[$index] = $type; + } + } + + /** + * 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 + * type is read with the RAW (un-generic-resolved) acceptor parameter pushed + * onto the in-function-call stack, so its body sees the template parameter + * (effectively mixed for an untyped param) rather than a parameter already + * resolved from sibling args. That keeps the inferred return type (the U in + * callable(T): U) faithful to the closure's own declaration. + * + * @param ParametersAcceptor[] $parametersAcceptors + */ + private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $closureExpr, MutatingScope $scope): Type + { + $rawParameter = null; + if (count($parametersAcceptors) === 1) { + $rawParameters = $parametersAcceptors[0]->getParameters(); + if (isset($rawParameters[$i])) { + $rawParameter = $rawParameters[$i]; + } elseif (count($rawParameters) > 0 && $parametersAcceptors[0]->isVariadic()) { + $rawParameter = array_last($rawParameters); + } + } + + if ($rawParameter !== null) { + $scope = $scope->pushInFunctionCall(null, $rawParameter, false); + } + + return $this->resolveCallableTypeForScope($closureExpr, $scope); + } + + /** + * @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); + } + + /** + * 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; + } + + $keptValueIds = []; + foreach ($normalizedCall->getArgs() as $normalizedArg) { + $keptValueIds[spl_object_id($normalizedArg->value)] = true; + } + + foreach ($originalCall->getArgs() as $originalArg) { + if (isset($keptValueIds[spl_object_id($originalArg->value)])) { + continue; + } + + $this->processExprNode($stmt, $originalArg->value, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + } } /** @@ -4045,19 +5282,54 @@ 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 { - return $this->container->getByType(AssignHandler::class)->processAssignVar( + // 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 = new VirtualAssignNodeCallback($nodeCallback); + $target = $assignHandler->prepareTarget( $this, $scope, $storage, $stmt, $var, $assignedExpr, - new VirtualAssignNodeCallback($nodeCallback), + $virtualAssignNodeCallback, + ExpressionContext::createDeep(), + AssignTargetWalkMode::virtualAssign(), + ); + + return $assignHandler->applyWrite( + $this, + $target, + $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, ExpressionContext::createDeep(), - fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $assignedExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), - false, ); } @@ -4124,14 +5396,15 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS $scope = $scope->assignVariable( $name, $varTag->getType(), - $scope->getNativeType($variableNode), + // a plain variable read is scope state - no synthetic pricing + $this->readTypeOfMaybeStored($variableNode, $scope->doNotTreatPhpDocTypesAsCertain()), $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); @@ -4197,6 +5470,8 @@ private function tryProcessUnrolledConstantArrayForeach( MutatingScope $originalScope, ExpressionResultStorage $originalStorage, StatementContext $context, + Type $iterateeType, + Type $nativeIterateeType, ): ?array { if ($stmt->byRef) { @@ -4209,7 +5484,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $iterateeType = $originalScope->getType($stmt->expr); if (!$iterateeType->isConstantArray()->yes()) { return null; } @@ -4235,7 +5509,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $nativeIterateeType = $originalScope->getNativeType($stmt->expr); $nativeConstantArrays = $nativeIterateeType->getConstantArrays(); $matchedNativeArrays = count($nativeConstantArrays) === count($constantArrays) ? $nativeConstantArrays : null; @@ -4371,7 +5644,7 @@ private function tryProcessUnrolledConstantArrayForeach( $prevLoopScope = $loopScope; $iterStorage = $originalStorage->duplicate(); $iterBodyScope = $loopScope->mergeWith($endScope); - $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $stmt, new NoopNodeCallback()); + $iterBodyScope = $this->enterForeach($iterBodyScope, $iterStorage, $originalScope, $stmt, $iterateeType, $nativeIterateeType, new NoopNodeCallback()); $iterBodyScopeResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); $loopScope = $iterBodyScopeResult->getScope(); foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { @@ -4396,9 +5669,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()) { @@ -4430,13 +5702,12 @@ private function getTraversableForeachThrowPoint(MutatingScope $scope, Expr $ite /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ - private function enterForeach(MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Foreach_ $stmt, callable $nodeCallback): MutatingScope + private function enterForeach(MutatingScope $scope, ExpressionResultStorage $storage, MutatingScope $originalScope, Foreach_ $stmt, Type $iterateeType, Type $nativeIterateeType, callable $nodeCallback): MutatingScope { if ($stmt->expr instanceof Variable && is_string($stmt->expr->name)) { $scope = $this->processVarAnnotation($scope, [$stmt->expr->name], $stmt); } - $iterateeType = $originalScope->getType($stmt->expr); if ( ($stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name)) && ($stmt->keyVar === null || ($stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name))) @@ -4445,6 +5716,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $scope = $scope->enterForeach( $originalScope, $stmt->expr, + $iterateeType, + $nativeIterateeType, $stmt->valueVar->name, $keyVarName, $stmt->byRef, @@ -4461,7 +5734,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $stmt->valueVar, new NativeTypeExpr( $originalScope->getIterableValueType($iterateeType), - $originalScope->getIterableValueType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableValueType($nativeIterateeType), ), $nodeCallback, )->getScope(); @@ -4469,7 +5742,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto if ( $stmt->keyVar instanceof Variable && is_string($stmt->keyVar->name) ) { - $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $stmt->keyVar->name); + $scope = $scope->enterForeachKey($originalScope, $stmt->expr, $iterateeType, $nativeIterateeType, $stmt->keyVar->name); $vars[] = $stmt->keyVar->name; } elseif ($stmt->keyVar !== null) { $scope = $this->processVirtualAssign( @@ -4479,7 +5752,7 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $stmt->keyVar, new NativeTypeExpr( $originalScope->getIterableKeyType($iterateeType), - $originalScope->getIterableKeyType($originalScope->getNativeType($stmt->expr)), + $originalScope->getIterableKeyType($nativeIterateeType), ), $nodeCallback, )->getScope(); @@ -4543,8 +5816,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(), ); } } @@ -4842,7 +6115,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; } @@ -5198,7 +6471,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++) {...} @@ -5229,12 +6502,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(), ); } } @@ -5254,12 +6527,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/PerFileAnalysisResettable.php b/src/Analyser/PerFileAnalysisResettable.php new file mode 100644 index 00000000000..6722079a043 --- /dev/null +++ b/src/Analyser/PerFileAnalysisResettable.php @@ -0,0 +1,22 @@ +|null $dimFetchStack + * @param non-empty-list|null $offsetTypes + * @param non-empty-list|null $offsetNativeTypes + * @param non-empty-list|null $existingOffsetTypes + * @param non-empty-list|null $existingOffsetNativeTypes + * @param ExpressionResult[] $targetChainResults + */ + public function __construct( + private string $kind, + private Expr $var, + private Expr $assignedExpr, + private MutatingScope $beforeScope, + private MutatingScope $scope, + private bool $enterExpressionAssign, + private bool $isAssignOp, + private bool $hasYield, + private array $throwPoints, + 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 $objectResult = null, + private ?string $propertyName = null, + private ?Type $propertyHolderType = null, + private ?ExpressionResult $targetReadResult = null, + private array $targetChainResults = [], + ) + { + } + + /** + * @return self::KIND_* + */ + public function getKind(): string + { + return $this->kind; + } + + public function getVar(): Expr + { + return $this->var; + } + + public function getAssignedExpr(): Expr + { + return $this->assignedExpr; + } + + public function getBeforeScope(): MutatingScope + { + return $this->beforeScope; + } + + /** The scope the caller processes the assigned value on. */ + public function getScope(): MutatingScope + { + return $this->scope; + } + + public function enterExpressionAssign(): bool + { + return $this->enterExpressionAssign; + } + + public function isAssignOp(): bool + { + return $this->isAssignOp; + } + + public function hasYield(): bool + { + return $this->hasYield; + } + + /** + * @return InternalThrowPoint[] + */ + public function getThrowPoints(): array + { + return $this->throwPoints; + } + + /** + * @return ImpurePoint[] + */ + public function getImpurePoints(): array + { + return $this->impurePoints; + } + + public function isAlwaysTerminating(): bool + { + return $this->isAlwaysTerminating; + } + + public function getRootVar(): Expr + { + if ($this->rootVar === null) { + throw new ShouldNotHappenException(); + } + + return $this->rootVar; + } + + public function getVarResult(): ExpressionResult + { + if ($this->varResult === null) { + throw new ShouldNotHappenException(); + } + + return $this->varResult; + } + + /** + * @return non-empty-list + */ + public function getDimFetchStack(): array + { + if ($this->dimFetchStack === null) { + throw new ShouldNotHappenException(); + } + + return $this->dimFetchStack; + } + + public function getAssignedPropertyExpr(): Expr + { + if ($this->assignedPropertyExpr === null) { + throw new ShouldNotHappenException(); + } + + return $this->assignedPropertyExpr; + } + + /** + * @return non-empty-list + */ + public function getOffsetTypes(): array + { + if ($this->offsetTypes === null) { + throw new ShouldNotHappenException(); + } + + return $this->offsetTypes; + } + + /** + * @return non-empty-list + */ + public function getOffsetNativeTypes(): array + { + if ($this->offsetNativeTypes === null) { + throw new ShouldNotHappenException(); + } + + return $this->offsetNativeTypes; + } + + /** + * @return non-empty-list + */ + public function getExistingOffsetTypes(): array + { + if ($this->existingOffsetTypes === null) { + throw new ShouldNotHappenException(); + } + + return $this->existingOffsetTypes; + } + + /** + * @return non-empty-list + */ + public function getExistingOffsetNativeTypes(): array + { + if ($this->existingOffsetNativeTypes === null) { + throw new ShouldNotHappenException(); + } + + return $this->existingOffsetNativeTypes; + } + + public function getObjectResult(): ExpressionResult + { + if ($this->objectResult === null) { + throw new ShouldNotHappenException(); + } + + return $this->objectResult; + } + + public function getPropertyName(): ?string + { + return $this->propertyName; + } + + public function getPropertyHolderType(): Type + { + if ($this->propertyHolderType === null) { + throw new ShouldNotHappenException(); + } + + return $this->propertyHolderType; + } + + /** + * The whole target priced as a read - produced only in the + * read-modify-write walk modes. + */ + public function getTargetReadResult(): ExpressionResult + { + if ($this->targetReadResult === null) { + throw new ShouldNotHappenException(); + } + + return $this->targetReadResult; + } + + /** + * @return ExpressionResult[] + */ + public function getTargetChainResults(): array + { + return $this->targetChainResults; + } + +} diff --git a/src/Analyser/ProcessArrowFunctionResult.php b/src/Analyser/ProcessArrowFunctionResult.php new file mode 100644 index 00000000000..a571ced99ff --- /dev/null +++ b/src/Analyser/ProcessArrowFunctionResult.php @@ -0,0 +1,59 @@ +expressionResult; + } + + public function getArrowFunctionScope(): MutatingScope + { + return $this->arrowFunctionScope; + } + + /** + * @return ThrowPoint[] + */ + public function getClosureTypeThrowPoints(): array + { + return $this->closureTypeThrowPoints; + } + + /** + * @return ImpurePoint[] + */ + public function getClosureTypeImpurePoints(): array + { + return $this->closureTypeImpurePoints; + } + + /** + * @return InvalidateExprNode[] + */ + public function getInvalidateExpressions(): array + { + return $this->invalidateExpressions; + } + +} diff --git a/src/Analyser/ProcessClosureResult.php b/src/Analyser/ProcessClosureResult.php index cb4b609e1f4..e114ac55b81 100644 --- a/src/Analyser/ProcessClosureResult.php +++ b/src/Analyser/ProcessClosureResult.php @@ -3,6 +3,10 @@ namespace PHPStan\Analyser; use PhpParser\Node\ClosureUse; +use PhpParser\Node\Expr\Yield_; +use PhpParser\Node\Expr\YieldFrom; +use PhpParser\Node\Stmt\Return_; +use PHPStan\Node\ExecutionEndNode; use PHPStan\Node\InvalidateExprNode; final class ProcessClosureResult @@ -12,6 +16,10 @@ final class ProcessClosureResult * @param InternalThrowPoint[] $throwPoints * @param ImpurePoint[] $impurePoints * @param InvalidateExprNode[] $invalidateExpressions + * @param list $gatheredReturnStatements + * @param list $gatheredYieldStatements + * @param list $executionEnds + * @param ImpurePoint[] $closureTypeImpurePoints already merged in getClosureType() order (property-assign impure points first, then statement result impure points) * @param ClosureUse[] $byRefUses */ public function __construct( @@ -19,6 +27,10 @@ public function __construct( private array $throwPoints, private array $impurePoints, private array $invalidateExpressions, + private array $gatheredReturnStatements, + private array $gatheredYieldStatements, + private array $executionEnds, + private array $closureTypeImpurePoints, private ?MutatingScope $byRefClosureResultScope = null, private array $byRefUses = [], ) @@ -63,4 +75,40 @@ public function getInvalidateExpressions(): array return $this->invalidateExpressions; } + /** + * @return list + */ + public function getGatheredReturnStatements(): array + { + return $this->gatheredReturnStatements; + } + + /** + * @return list + */ + public function getGatheredYieldStatements(): array + { + return $this->gatheredYieldStatements; + } + + /** + * @return list + */ + public function getExecutionEnds(): array + { + return $this->executionEnds; + } + + /** + * The closure body's impure points already merged in getClosureType() order + * (property-assign impure points first, then statement result impure points), + * ready to feed ClosureTypeResolver::buildClosureTypeForClosure(). + * + * @return ImpurePoint[] + */ + public function getClosureTypeImpurePoints(): array + { + return $this->closureTypeImpurePoints; + } + } 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/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); diff --git a/src/Analyser/ScopeOps.php b/src/Analyser/ScopeOps.php index e5fb684eadd..cc03648eee6 100644 --- a/src/Analyser/ScopeOps.php +++ b/src/Analyser/ScopeOps.php @@ -4,7 +4,6 @@ use PhpParser\Node; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\Array_; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\PropertyFetch; @@ -27,17 +26,14 @@ use function array_key_exists; use function array_key_first; use function array_keys; -use function array_slice; use function count; use function get_class; use function in_array; -use function is_array; use function is_string; use function str_contains; use function strlen; use function strpos; use function substr_compare; -use function usort; /** * Hot scope-table operations extracted from MutatingScope. @@ -75,10 +71,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; } @@ -535,54 +527,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 = !str_contains($exprStringToInvalidate, '__phpstan') + && !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. @@ -606,25 +637,8 @@ 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. - $canUseKeyPrefilter = $exprStringToInvalidate !== '$this' - && !str_contains($exprStringToInvalidate, '__phpstan') - && !str_contains($exprStringToInvalidate, '/*'); - foreach ($expressionTypes as $exprString => $exprTypeHolder) { - $exprString = (string) $exprString; - if ( - $canUseKeyPrefilter - && !str_contains($exprString, $exprStringToInvalidate) - && !self::keyMayHideSubExpressions($exprString) - ) { - continue; - } - $exprExpr = $exprTypeHolder->getExpr(); - if (!self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $exprExpr, $exprString, $requireMoreCharacters, $invalidatingClass)) { + if (!self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $exprTypeHolder, $exprString, $requireMoreCharacters, $invalidatingClass)) { continue; } @@ -638,66 +652,27 @@ 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)) { - $invalidated = true; - continue; - } + $firstHolder = $holders[array_key_first($holders)]->getTypeHolder(); + if (self::shouldInvalidateExpression($scope, $exprPrinter, $exprStringToInvalidate, $expressionToInvalidate, $firstHolder, self::nodeKey($firstHolder->getExpr(), $exprPrinter), $requireMoreCharacters, $invalidatingClass)) { + $invalidated = true; + continue; } - // Lazily materialized: stays null while every holder seen so far is kept (so far - // always the first $keptCount ones), so the common no-drop case reuses the - // original array instead of rebuilding it holder by holder. - $filteredHolders = null; - $keptCount = 0; + $filteredHolders = []; foreach ($holders as $key => $holder) { - // The holder's array key (ConditionalExpressionHolder::getKey()) embeds every - // condition's expression key verbatim, so when the invalidated key does not - // occur in it, none of the conditions can contain the invalidated expression - // and the holder can be kept without inspecting its conditions. - if ( - $canUseKeyPrefilter - && !str_contains((string) $key, $exprStringToInvalidate) - && !self::keyMayHideSubExpressions((string) $key) - ) { - if ($filteredHolders !== null) { - $filteredHolders[$key] = $holder; - } else { - $keptCount++; - } - continue; - } $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, $conditionalTypeHolderExprString, invalidatingClass: $invalidatingClass)) { $invalidated = true; $shouldKeep = false; break; } } - if ($shouldKeep) { - if ($filteredHolders !== null) { - $filteredHolders[$key] = $holder; - } else { - $keptCount++; - } + if (!$shouldKeep) { continue; } - $filteredHolders ??= array_slice($holders, 0, $keptCount, true); - } - if ($filteredHolders === null) { - $newConditionalExpressions[$conditionalExprString] = $holders; - continue; + $filteredHolders[$key] = $holder; } if (count($filteredHolders) <= 0) { continue; @@ -714,93 +689,14 @@ 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. - * - * @param array $expressionTypes - * @param array $nativeExpressionTypes - * @return array{array, array}|null - */ - public static function invalidateMethodsOnExpression( - ExprPrinter $exprPrinter, - string $exprStringToInvalidate, - array $expressionTypes, - array $nativeExpressionTypes, - ): ?array - { - $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 = !str_contains($exprStringToInvalidate, '__phpstan') - && !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 (!$invalidated) { - return null; - } - - 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; - } - } - - return true; - } - - return false; - } - - /** - * Mirrors the former MutatingScope::shouldInvalidateExpression(). + * Answers one (stored holder, invalidated expression) containment check + * from the holder's contained-node-keys index; '$this' invalidations keep + * the finder because they also match self/static/parent and the current + * class name, whose resolution depends on the asking scope. */ - public static function shouldInvalidateExpression(MutatingScope $scope, ExprPrinter $exprPrinter, string $exprStringToInvalidate, Expr $exprToInvalidate, Expr $expr, string $exprString, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): bool + public static function shouldInvalidateExpression(MutatingScope $scope, ExprPrinter $exprPrinter, string $exprStringToInvalidate, Expr $exprToInvalidate, ExpressionTypeHolder $exprTypeHolder, string $exprString, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): bool { + $expr = $exprTypeHolder->getExpr(); if ( $expr instanceof IntertwinedVariableByReferenceWithExpr && $exprToInvalidate instanceof Variable @@ -819,40 +715,58 @@ public static function shouldInvalidateExpression(MutatingScope $scope, ExprPrin } // Variables will not contain traversable expressions. skip the NodeFinder overhead - if ($expr instanceof Variable && is_string($expr->name)) { - if ($requireMoreCharacters) { - // a variable cannot contain more than itself, and the exact match - // was already rejected above - this also covers the '$this' - // invalidation run for every impure method call, where the substring - // gate below cannot be used - return false; - } - + if ($expr instanceof Variable && is_string($expr->name) && !$requireMoreCharacters) { 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' && !str_contains($exprStringToInvalidate, '__phpstan') && !str_contains($exprStringToInvalidate, '/*') + && !str_contains($exprString, '__phpstan') && !str_contains($exprString, $exprStringToInvalidate) - && !self::keyMayHideSubExpressions($exprString) ) { return false; } - if (!self::containsExpressionToInvalidate($scope, $exprPrinter, $expr, get_class($exprToInvalidate), $exprStringToInvalidate)) { + if ($exprStringToInvalidate === '$this') { + // '$this' also matches self/static/parent and the current class name - + // name resolution depends on this scope, so the holder-cached key + // index below cannot answer it + $nodeFinder = new NodeFinder(); + $expressionToInvalidateClass = get_class($exprToInvalidate); + $found = $nodeFinder->findFirst([$expr], static function (Node $node) use ($scope, $exprPrinter, $expressionToInvalidateClass, $exprStringToInvalidate): bool { + if ( + $node instanceof Name + && ( + in_array($node->toLowerString(), ['self', 'static', 'parent'], true) + || ($scope->getClassReflection() !== null && $scope->getClassReflection()->is($scope->resolveName($node))) + ) + ) { + return true; + } + + if (!$node instanceof $expressionToInvalidateClass) { + return false; + } + + return self::nodeKey($node, $exprPrinter) === $exprStringToInvalidate; + }); + + if ($found === null) { + return false; + } + } elseif (!isset($exprTypeHolder->getContainedNodeKeys(static fn (Expr $node): string => self::nodeKey($node, $exprPrinter))[$exprStringToInvalidate][get_class($exprToInvalidate)])) { return false; } @@ -875,62 +789,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 sorted type-specification list of MutatingScope::filterBySpecifiedTypes(). - * - * @param array $sureTypes - * @param array $sureNotTypes - * @return list - */ - public static function buildTypeSpecifications(array $sureTypes, array $sureNotTypes): array - { - $typeSpecifications = []; - foreach ($sureTypes as $exprString => [$expr, $type]) { - if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { - continue; - } - $typeSpecifications[] = [ - 'sure' => true, - 'exprString' => (string) $exprString, - 'expr' => $expr, - 'type' => $type, - ]; - } - foreach ($sureNotTypes as $exprString => [$expr, $type]) { - if ($expr instanceof Node\Scalar || $expr instanceof Array_ || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { - continue; - } - $typeSpecifications[] = [ - 'sure' => false, - 'exprString' => (string) $exprString, - 'expr' => $expr, - 'type' => $type, - ]; - } - - usort($typeSpecifications, static function (array $a, array $b): int { - $length = strlen($a['exprString']) - strlen($b['exprString']); - if ($length !== 0) { - return $length; - } - - return $b['sure'] - $a['sure']; // @phpstan-ignore minus.leftNonNumeric, minus.rightNonNumeric - }); - - return $typeSpecifications; - } - /** * The conditional-expressions fixed-point matching of * MutatingScope::filterBySpecifiedTypes(). diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 222020de5c5..28cd43ad587 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; @@ -12,11 +13,31 @@ final class SpecifiedTypes { + /** @var (Closure(TypeSpecifierContext, bool): self)|null */ + private static ?Closure $emptySpecifyCallback = null; + private bool $overwrite = false; /** @var array */ private array $newConditionalExpressionHolders = []; + /** + * Deferred boolean-decomposition holders, evaluated against the applying + * scope by MutatingScope::applySpecifiedTypes(). + * + * @var list + */ + private array $conditionalExpressionHolderRecipes = []; + + /** + * State-dependent augmentations evaluated against the applying scope by + * MutatingScope::applySpecifiedTypes(); their entries join the applied + * batch. + * + * @var list + */ + private array $deferredAugments = []; + private ?Expr $rootExpr = null; /** @@ -44,6 +65,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. @@ -63,11 +96,8 @@ public function __construct( */ public function setAlwaysOverwriteTypes(): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; + $self = clone $this; $self->overwrite = true; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->rootExpr = $this->rootExpr; return $self; } @@ -77,10 +107,7 @@ public function setAlwaysOverwriteTypes(): self */ public function setRootExpr(?Expr $rootExpr): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; + $self = clone $this; $self->rootExpr = $rootExpr; return $self; @@ -91,15 +118,47 @@ public function setRootExpr(?Expr $rootExpr): self */ public function setNewConditionalExpressionHolders(array $newConditionalExpressionHolders): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $this->alternativeTypes; - $self->overwrite = $this->overwrite; + $self = clone $this; $self->newConditionalExpressionHolders = $newConditionalExpressionHolders; - $self->rootExpr = $this->rootExpr; return $self; } + /** + * @param list $recipes + */ + public function setConditionalExpressionHolderRecipes(array $recipes): self + { + $self = clone $this; + $self->conditionalExpressionHolderRecipes = $recipes; + + return $self; + } + + /** + * @return list + */ + public function getConditionalExpressionHolderRecipes(): array + { + return $this->conditionalExpressionHolderRecipes; + } + + public function withDeferredAugment(DeferredSpecifiedTypesAugment $augment): self + { + $self = clone $this; + $self->deferredAugments = [...$this->deferredAugments, $augment]; + + return $self; + } + + /** + * @return list + */ + public function getDeferredAugments(): array + { + return $this->deferredAugments; + } + /** * @api * @return array @@ -127,17 +186,16 @@ public function getAlternativeTypes(): array } /** - * 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. + * A copy without conditional-expression holders and holder recipes - for + * the boolean-decomposition tails that replace them with freshly built + * recipes while keeping everything else (entries, alternatives, augments) + * intact. */ - public function withAlternativeTypesOf(self $other): self + public function withoutConditionalExpressionHolders(): self { - $self = new self($this->sureTypes, $this->sureNotTypes); - $self->alternativeTypes = $other->alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->rootExpr = $this->rootExpr; + $self = clone $this; + $self->newConditionalExpressionHolders = []; + $self->conditionalExpressionHolderRecipes = []; return $self; } @@ -162,18 +220,10 @@ public function getRootExpr(): ?Expr public function removeExpr(string $exprString): self { - $sureTypes = $this->sureTypes; - $sureNotTypes = $this->sureNotTypes; - $alternativeTypes = $this->alternativeTypes; - unset($sureTypes[$exprString]); - unset($sureNotTypes[$exprString]); - unset($alternativeTypes[$exprString]); - - $self = new self($sureTypes, $sureNotTypes); - $self->alternativeTypes = $alternativeTypes; - $self->overwrite = $this->overwrite; - $self->newConditionalExpressionHolders = $this->newConditionalExpressionHolders; - $self->rootExpr = $this->rootExpr; + $self = clone $this; + unset($self->sureTypes[$exprString]); + unset($self->sureNotTypes[$exprString]); + unset($self->alternativeTypes[$exprString]); return $self; } @@ -344,6 +394,8 @@ public function unionWith(SpecifiedTypes $other): self } } $result->newConditionalExpressionHolders = $conditionalExpressionHolders; + $result->conditionalExpressionHolderRecipes = array_merge($this->conditionalExpressionHolderRecipes, $other->conditionalExpressionHolderRecipes); + $result->deferredAugments = array_merge($this->deferredAugments, $other->deferredAugments); return $result->setRootExpr($rootExpr); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3bc9039336b..b51b8e339cc 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,41 +12,16 @@ 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\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 @@ -91,148 +65,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); } /** @@ -268,230 +106,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 */ @@ -507,11 +121,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; @@ -548,10 +157,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/Node/CoalesceExpressionNode.php b/src/Node/CoalesceExpressionNode.php new file mode 100644 index 00000000000..e8653bbc426 --- /dev/null +++ b/src/Node/CoalesceExpressionNode.php @@ -0,0 +1,59 @@ +getAttributes()); + } + + public function getOriginalExpr(): Expr + { + return $this->originalExpr; + } + + public function getSubjectResult(): ExpressionResult + { + return $this->subjectResult; + } + + public function getOperatorDescription(): string + { + return $this->operatorDescription; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_CoalesceExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/EmptyExpressionNode.php b/src/Node/EmptyExpressionNode.php new file mode 100644 index 00000000000..e6685101a76 --- /dev/null +++ b/src/Node/EmptyExpressionNode.php @@ -0,0 +1,45 @@ +getAttributes()); + } + + public function getExprResult(): ExpressionResult + { + return $this->exprResult; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_EmptyExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/FunctionCallExpressionNode.php b/src/Node/FunctionCallExpressionNode.php new file mode 100644 index 00000000000..3f65dc4890a --- /dev/null +++ b/src/Node/FunctionCallExpressionNode.php @@ -0,0 +1,55 @@ +getAttributes()); + } + + public function getOriginalNode(): FuncCall + { + return $this->originalNode; + } + + public function getResult(): ExpressionResult + { + return $this->result; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_FunctionCallExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/IssetExpressionNode.php b/src/Node/IssetExpressionNode.php new file mode 100644 index 00000000000..6c8bc870198 --- /dev/null +++ b/src/Node/IssetExpressionNode.php @@ -0,0 +1,51 @@ +getAttributes()); + } + + /** + * @return ExpressionResult[] + */ + public function getVarResults(): array + { + return $this->varResults; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_IssetExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/MethodCallExpressionNode.php b/src/Node/MethodCallExpressionNode.php new file mode 100644 index 00000000000..dc9ff5f8975 --- /dev/null +++ b/src/Node/MethodCallExpressionNode.php @@ -0,0 +1,54 @@ +getAttributes()); + } + + public function getOriginalNode(): MethodCall + { + return $this->originalNode; + } + + public function getResult(): ExpressionResult + { + return $this->result; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_MethodCallExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/NullsafeMethodCallExpressionNode.php b/src/Node/NullsafeMethodCallExpressionNode.php new file mode 100644 index 00000000000..bf8aa01c3e1 --- /dev/null +++ b/src/Node/NullsafeMethodCallExpressionNode.php @@ -0,0 +1,59 @@ +getAttributes()); + } + + public function getOriginalNode(): NullsafeMethodCall + { + return $this->originalNode; + } + + public function getCalledOnType(): Type + { + return $this->calledOnType; + } + + public function getCalledOnNativeType(): Type + { + return $this->calledOnNativeType; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_NullsafeMethodCallExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/NullsafePropertyFetchExpressionNode.php b/src/Node/NullsafePropertyFetchExpressionNode.php new file mode 100644 index 00000000000..d58ba8c7db9 --- /dev/null +++ b/src/Node/NullsafePropertyFetchExpressionNode.php @@ -0,0 +1,60 @@ +getAttributes()); + } + + public function getOriginalNode(): NullsafePropertyFetch + { + return $this->originalNode; + } + + public function getCalledOnType(): Type + { + return $this->calledOnType; + } + + public function getCalledOnNativeType(): Type + { + return $this->calledOnNativeType; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_NullsafePropertyFetchExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Node/StaticMethodCallExpressionNode.php b/src/Node/StaticMethodCallExpressionNode.php new file mode 100644 index 00000000000..98b07afec26 --- /dev/null +++ b/src/Node/StaticMethodCallExpressionNode.php @@ -0,0 +1,54 @@ +getAttributes()); + } + + public function getOriginalNode(): StaticCall + { + return $this->originalNode; + } + + public function getResult(): ExpressionResult + { + return $this->result; + } + + #[Override] + public function getType(): string + { + return 'PHPStan_Node_StaticMethodCallExpressionNode'; + } + + /** + * @return string[] + */ + #[Override] + public function getSubNodeNames(): array + { + return []; + } + +} diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 24751d5f65f..d3d1773f802 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -4,6 +4,7 @@ use Closure; use PhpParser\Node; +use PhpParser\Node\Expr; use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; @@ -80,6 +81,124 @@ public static function selectFromArgs( { $types = []; $unpack = false; + $parametersAcceptors = self::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), + ); + + if (count($parametersAcceptors) === 1) { + $acceptor = $parametersAcceptors[0]; + if (!self::hasAcceptorTemplateOrLateResolvableType($acceptor)) { + return $acceptor; + } + } + + $reorderedArgs = $args; + $parameters = null; + $singleParametersAcceptor = null; + if (count($parametersAcceptors) === 1) { + if (!array_is_list($args)) { + // actually $args parameter should be typed to list but we can't atm, + // because its a BC break. + $args = array_values($args); + } + $reorderedArgs = ArgumentsNormalizer::reorderArgs($parametersAcceptors[0], $args); + $singleParametersAcceptor = $parametersAcceptors[0]; + } + + $hasName = false; + foreach ($reorderedArgs ?? $args as $i => $arg) { + $originalArg = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; + $parameter = null; + if ($singleParametersAcceptor !== null) { + $parameters = $singleParametersAcceptor->getParameters(); + if (isset($parameters[$i])) { + $parameter = $parameters[$i]; + } elseif (count($parameters) > 0 && $singleParametersAcceptor->isVariadic()) { + $parameter = array_last($parameters); + } + } + + if ($parameter !== null && $scope instanceof MutatingScope) { + $rememberTypes = !$originalArg->value instanceof Node\Expr\Closure && !$originalArg->value instanceof Node\Expr\ArrowFunction; + $scope = $scope->pushInFunctionCall(null, $parameter, $rememberTypes); + } + + $type = $scope->getType($originalArg->value); + + if ($parameter !== null && $scope instanceof MutatingScope) { + $scope = $scope->popInFunctionCall(); + } + + if ($originalArg->name !== null) { + $index = $originalArg->name->toString(); + $hasName = true; + } else { + $index = $i; + } + if ($originalArg->unpack) { + $unpack = true; + $constantArrays = $type->getConstantArrays(); + if (count($constantArrays) > 0) { + foreach ($constantArrays as $constantArray) { + $values = $constantArray->getValueTypes(); + foreach ($constantArray->getKeyTypes() as $j => $keyType) { + $valueType = $values[$j]; + $valueIndex = $keyType->getValue(); + if (is_string($valueIndex)) { + $hasName = true; + } else { + $valueIndex = $i + $j; + } + + $types[$valueIndex] = isset($types[$valueIndex]) + ? TypeCombinator::union($types[$valueIndex], $valueType) + : $valueType; + } + } + } else { + $types[$index] = $type->getIterableValueType(); + } + } else { + $types[$index] = $type; + } + } + + if ($hasName && $namedArgumentsVariants !== null) { + return self::selectFromTypes($types, $namedArgumentsVariants, $unpack); + } + + return self::selectFromTypes($types, $parametersAcceptors, $unpack); + } + + /** + * @internal + * @param Node\Arg[] $args + * @param ParametersAcceptor[] $parametersAcceptors + * @param ParametersAcceptor[]|null $namedArgumentsVariants + * @param Closure(Expr): Type $typeGetter + * @param Closure(Expr): Type $nativeTypeGetter + * @param Closure(Type): Type $iterableValueTypeGetter + * @param Closure(Type): Type $iterableKeyTypeGetter + * @return ParametersAcceptor[] + */ + public static function applyIntrinsicArgOverrides( + array $args, + array $parametersAcceptors, + ?array $namedArgumentsVariants, + Scope $scope, + Closure $typeGetter, + Closure $nativeTypeGetter, + Closure $iterableValueTypeGetter, + Closure $iterableKeyTypeGetter, + ): array + { if ( count($args) > 0 && count($parametersAcceptors) > 0 @@ -89,15 +208,15 @@ public static function selectFromArgs( $callbackParameters = []; $nativeCallbackParameters = []; foreach ($arrayMapArgs as $arg) { - $argType = $scope->getType($arg->value); - $nativeArgType = $scope->getNativeType($arg->value); + $argType = ($typeGetter)($arg->value); + $nativeArgType = ($nativeTypeGetter)($arg->value); if ($arg->unpack) { $constantArrays = $argType->getConstantArrays(); if (count($constantArrays) > 0) { foreach ($constantArrays as $constantArray) { $valueTypes = $constantArray->getValueTypes(); foreach ($valueTypes as $valueType) { - $callbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } } @@ -106,13 +225,13 @@ public static function selectFromArgs( foreach ($nativeConstantArrays as $constantArray) { $valueTypes = $constantArray->getValueTypes(); foreach ($valueTypes as $valueType) { - $nativeCallbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($valueType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } } } else { - $callbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeCallbackParameters[] = new DummyParameter('item', $scope->getIterableValueType($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $callbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeCallbackParameters[] = new DummyParameter('item', ($iterableValueTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } } @@ -133,7 +252,7 @@ public static function selectFromArgs( } if (count($args) >= 3 && (bool) $args[0]->getAttribute(CurlSetOptArgVisitor::ATTRIBUTE_NAME)) { - $optType = $scope->getType($args[1]->value); + $optType = ($typeGetter)($args[1]->value); $valueTypes = []; foreach ($optType->getConstantScalarValues() as $scalarValue) { @@ -177,7 +296,7 @@ public static function selectFromArgs( } if (count($args) >= 2 && (bool) $args[1]->getAttribute(CurlSetOptArrayArgVisitor::ATTRIBUTE_NAME)) { - $optArrayType = $scope->getType($args[1]->value); + $optArrayType = ($typeGetter)($args[1]->value); $hasTypes = false; $builder = ConstantArrayTypeBuilder::createEmpty(); @@ -232,23 +351,23 @@ public static function selectFromArgs( $arrayFilterParameters = null; $nativeArrayFilterParameters = null; if (isset($args[2])) { - $mode = $scope->getType($args[2]->value); + $mode = ($typeGetter)($args[2]->value); if ($mode instanceof ConstantIntegerType) { if ($mode->getValue() === ARRAY_FILTER_USE_KEY) { $arrayFilterParameters = [ - new DummyParameter('key', $scope->getIterableKeyType($scope->getType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)(($typeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; $nativeArrayFilterParameters = [ - new DummyParameter('key', $scope->getIterableKeyType($scope->getNativeType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)(($nativeTypeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; } elseif ($mode->getValue() === ARRAY_FILTER_USE_BOTH) { $arrayFilterParameters = [ - new DummyParameter('item', $scope->getIterableValueType($scope->getType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($scope->getType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)(($typeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)(($typeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; $nativeArrayFilterParameters = [ - new DummyParameter('item', $scope->getIterableValueType($scope->getNativeType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($scope->getNativeType($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)(($nativeTypeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)(($nativeTypeGetter)($args[0]->value)), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; } } @@ -257,22 +376,22 @@ public static function selectFromArgs( $acceptor = $parametersAcceptors[0]; $parameters = $acceptor->getParameters(); if (isset($parameters[1])) { - $arrayArgType = $scope->getType($args[0]->value); + $arrayArgType = ($typeGetter)($args[0]->value); $callableType = new UnionType([ new CallableType( $arrayFilterParameters ?? [ - new DummyParameter('item', $scope->getIterableValueType($arrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)($arrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ], new BooleanType(), false, ), new NullType(), ]); - $nativeArrayArgType = $scope->getNativeType($args[0]->value); + $nativeArrayArgType = ($nativeTypeGetter)($args[0]->value); $nativeCallableType = new UnionType([ new CallableType( $nativeArrayFilterParameters ?? [ - new DummyParameter('item', $scope->getIterableValueType($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ], new BooleanType(), false, @@ -314,19 +433,19 @@ public static function selectFromArgs( } if ((bool) $args[0]->getAttribute(ArrayWalkArgVisitor::ATTRIBUTE_NAME)) { - $arrayArgType = $scope->getType($args[0]->value); - $nativeArrayArgType = $scope->getNativeType($args[0]->value); + $arrayArgType = ($typeGetter)($args[0]->value); + $nativeArrayArgType = ($nativeTypeGetter)($args[0]->value); $arrayWalkParameters = [ - new DummyParameter('item', $scope->getIterableValueType($arrayArgType), optional: false, passedByReference: PassedByReference::createReadsArgument(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($arrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)($arrayArgType), optional: false, passedByReference: PassedByReference::createReadsArgument(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)($arrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; $nativeArrayWalkParameters = [ - new DummyParameter('item', $scope->getIterableValueType($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createReadsArgument(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('item', ($iterableValueTypeGetter)($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createReadsArgument(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)($nativeArrayArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ]; if (isset($args[2])) { - $arrayWalkParameters[] = new DummyParameter('arg', $scope->getType($args[2]->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); - $nativeArrayWalkParameters[] = new DummyParameter('arg', $scope->getNativeType($args[2]->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $arrayWalkParameters[] = new DummyParameter('arg', ($typeGetter)($args[2]->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); + $nativeArrayWalkParameters[] = new DummyParameter('arg', ($nativeTypeGetter)($args[2]->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } $acceptor = $parametersAcceptors[0]; @@ -343,20 +462,20 @@ public static function selectFromArgs( $acceptor = $parametersAcceptors[0]; $parameters = $acceptor->getParameters(); if (isset($parameters[1])) { - $argType = $scope->getType($args[0]->value); + $argType = ($typeGetter)($args[0]->value); $callableType = new CallableType( [ - new DummyParameter('value', $scope->getIterableValueType($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('value', ($iterableValueTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)($argType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ], new BooleanType(), false, ); - $nativeArgType = $scope->getNativeType($args[0]->value); + $nativeArgType = ($nativeTypeGetter)($args[0]->value); $nativeCallableType = new CallableType( [ - new DummyParameter('value', $scope->getIterableValueType($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), - new DummyParameter('key', $scope->getIterableKeyType($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('value', ($iterableValueTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), + new DummyParameter('key', ($iterableKeyTypeGetter)($nativeArgType), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null), ], new BooleanType(), false, @@ -371,7 +490,7 @@ public static function selectFromArgs( $closureBindToVar instanceof Node\Expr\Variable && is_string($closureBindToVar->name) ) { - $varType = $scope->getType($closureBindToVar); + $varType = ($typeGetter)($closureBindToVar); if ((new ObjectType(Closure::class))->isSuperTypeOf($varType)->yes()) { $inFunction = $scope->getFunction(); if ($inFunction !== null) { @@ -458,97 +577,23 @@ public static function selectFromArgs( } } - if (count($parametersAcceptors) === 1) { - $acceptor = $parametersAcceptors[0]; - if (!self::hasAcceptorTemplateOrLateResolvableType($acceptor)) { - return $acceptor; - } - } - - $reorderedArgs = $args; - $parameters = null; - $singleParametersAcceptor = null; - if (count($parametersAcceptors) === 1) { - if (!array_is_list($args)) { - // actually $args parameter should be typed to list but we can't atm, - // because its a BC break. - $args = array_values($args); - } - $reorderedArgs = ArgumentsNormalizer::reorderArgs($parametersAcceptors[0], $args); - $singleParametersAcceptor = $parametersAcceptors[0]; - } - - $hasName = false; - foreach ($reorderedArgs ?? $args as $i => $arg) { - $originalArg = $arg->getAttribute(ArgumentsNormalizer::ORIGINAL_ARG_ATTRIBUTE) ?? $arg; - $parameter = null; - if ($singleParametersAcceptor !== null) { - $parameters = $singleParametersAcceptor->getParameters(); - if (isset($parameters[$i])) { - $parameter = $parameters[$i]; - } elseif (count($parameters) > 0 && $singleParametersAcceptor->isVariadic()) { - $parameter = array_last($parameters); - } - } - - if ($parameter !== null && $scope instanceof MutatingScope) { - $rememberTypes = !$originalArg->value instanceof Node\Expr\Closure && !$originalArg->value instanceof Node\Expr\ArrowFunction; - $scope = $scope->pushInFunctionCall(null, $parameter, $rememberTypes); - } - - $type = $scope->getType($originalArg->value); - - if ($parameter !== null && $scope instanceof MutatingScope) { - $scope = $scope->popInFunctionCall(); - } - - if ($originalArg->name !== null) { - $index = $originalArg->name->toString(); - $hasName = true; - } else { - $index = $i; - } - if ($originalArg->unpack) { - $unpack = true; - $constantArrays = $type->getConstantArrays(); - if (count($constantArrays) > 0) { - foreach ($constantArrays as $constantArray) { - $values = $constantArray->getValueTypes(); - foreach ($constantArray->getKeyTypes() as $j => $keyType) { - $valueType = $values[$j]; - $valueIndex = $keyType->getValue(); - if (is_string($valueIndex)) { - $hasName = true; - } else { - $valueIndex = $i + $j; - } - - $types[$valueIndex] = isset($types[$valueIndex]) - ? TypeCombinator::union($types[$valueIndex], $valueType) - : $valueType; - } - } - } else { - $types[$index] = $type->getIterableValueType(); - } - } else { - $types[$index] = $type; - } - } - - if ($hasName && $namedArgumentsVariants !== null) { - return self::selectFromTypes($types, $namedArgumentsVariants, $unpack); - } - - return self::selectFromTypes($types, $parametersAcceptors, $unpack); + return $parametersAcceptors; } - private static function hasAcceptorTemplateOrLateResolvableType(ParametersAcceptor $acceptor): bool + /** + * @internal + */ + public static function hasAcceptorTemplateOrLateResolvableType(ParametersAcceptor $acceptor): bool { if ($acceptor->getReturnType()->hasTemplateOrLateResolvableType()) { return true; } + return self::hasAcceptorTemplateOrLateResolvableParameterType($acceptor); + } + + public static function hasAcceptorTemplateOrLateResolvableParameterType(ParametersAcceptor $acceptor): bool + { foreach ($acceptor->getParameters() as $parameter) { if ( $parameter instanceof ExtendedParameterReflection @@ -685,6 +730,34 @@ public static function selectFromTypes( return GenericParametersAcceptorResolver::resolve($types, self::combineAcceptors($winningAcceptors)); } + /** + * Picks the structural ParametersAcceptor (parameter names/positions/variadic + * only) that drives argument normalization / reordering. Unlike selectFromArgs() + * it never reads argument types from a Scope, so it is safe to call before the + * arguments have been processed - generics are resolved separately, type-driven. + * + * @internal + * @param Node\Arg[] $args + * @param ParametersAcceptor[] $variants + * @param ParametersAcceptor[]|null $namedArgumentsVariants + */ + public static function combineVariantsForNormalization(array $args, array $variants, ?array $namedArgumentsVariants): ParametersAcceptor + { + $hasName = false; + foreach ($args as $arg) { + if ($arg->name !== null) { + $hasName = true; + break; + } + } + + $selectedVariants = $hasName && $namedArgumentsVariants !== null ? $namedArgumentsVariants : $variants; + + return count($selectedVariants) === 1 + ? $selectedVariants[0] + : self::combineAcceptors($selectedVariants); + } + /** * @param ParametersAcceptor[] $acceptors */ diff --git a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php index 0c94a5082c7..aaebd12d095 100644 --- a/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php +++ b/src/Reflection/Php/PhpFunctionFromParserNodeReflection.php @@ -22,6 +22,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\Type; use PHPStan\Type\TypehintHelper; +use WeakMap; use function array_map; use function array_reverse; use function is_string; @@ -33,6 +34,14 @@ class PhpFunctionFromParserNodeReflection implements FunctionReflection, ExtendedParametersAcceptor { + /** + * The yield scan walks the whole body; reflections for the same node are + * recreated per ask, so the answer is memoized on the AST node itself. + * + * @var WeakMap|null + */ + private static ?WeakMap $generatorCache = null; + /** @var Function_|ClassMethod|Node\PropertyHook */ private Node\FunctionLike $functionLike; @@ -277,7 +286,9 @@ public function isBuiltin(): bool public function isGenerator(): bool { - return $this->nodeIsOrContainsYield($this->functionLike); + self::$generatorCache ??= new WeakMap(); + + return self::$generatorCache[$this->functionLike] ??= NodeScanner::nodeIsOrContainsYield($this->functionLike); } public function acceptsNamedArguments(): TrinaryLogic @@ -285,11 +296,6 @@ public function acceptsNamedArguments(): TrinaryLogic return TrinaryLogic::createFromBoolean($this->acceptsNamedArguments); } - private function nodeIsOrContainsYield(Node $node): bool - { - return NodeScanner::nodeIsOrContainsYield($node); - } - public function getAsserts(): Assertions { return $this->assertions; diff --git a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php index 7e755e9e36b..134dafbb3f5 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php @@ -8,13 +8,14 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\FunctionCallExpressionNode; use PHPStan\Parser\LastConditionVisitor; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class ImpossibleCheckTypeFunctionCallRule implements Rule @@ -37,72 +38,74 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\FuncCall::class; + return FunctionCallExpressionNode::class; } public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { - if (!$node->name instanceof Node\Name) { + $funcCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); + if (!$funcCall->name instanceof Node\Name) { return []; } - $functionName = (string) $node->name; + $functionName = (string) $funcCall->name; $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $funcCall, $nodeResult, $reasons); if ($isAlways === null) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $funcCall); return []; } - $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $node); + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $funcCall); - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $funcCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder->acceptsReasonsTip($reasons)); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } if (!$this->treatPhpDocTypesAsCertain) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $funcCall, $nodeResult, $reasons); if ($isAlways !== null) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); } if (!$this->treatPhpDocTypesAsCertainTip) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); } $ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $funcCall, $ruleErrorBuilder); }; if (!$isAlways) { $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to function %s()%s will always evaluate to false.', $functionName, - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $funcCall->getArgs()), ))); $ruleError = $errorBuilder->identifier('function.impossibleType')->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $funcCall, false, $ruleError); return []; } return [$ruleError]; } - $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + $isLast = $funcCall->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $funcCall); return []; } $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to function %s()%s will always evaluate to true.', $functionName, - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $funcCall->getArgs()), ))); if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); @@ -112,7 +115,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $ruleError = $errorBuilder->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $funcCall, true, $ruleError); return []; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 13f62f78cb6..9b2804436f4 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -8,9 +8,9 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; +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 +48,6 @@ final class ImpossibleCheckTypeHelper public function __construct( private ReflectionProvider $reflectionProvider, - private TypeSpecifier $typeSpecifier, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, ) @@ -62,10 +61,42 @@ public function __construct( public function findSpecifiedType( Scope $scope, Expr $node, + ExpressionResult $nodeResult, array &$reasons = [], ): ?bool { - $specifiedValue = $this->getSpecifiedType($scope, $node, $reasons); + return $this->doFindSpecifiedType($scope, $node, $nodeResult, $reasons); + } + + /** + * Variant for callers without an ExpressionResult to read from: the + * type-specifying-functions extension (runs while the call's own type is still + * being computed) and ConstantConditionRuleHelper (the condition is already + * processed on the scope). The narrowing is asked of the scope instead. + * + * @param list $reasons populated with human-readable explanations of why the + * result is "always false" (empty for the "always true" / inconclusive results) + */ + public function findSpecifiedTypeFromScope( + Scope $scope, + Expr $node, + array &$reasons = [], + ): ?bool + { + return $this->doFindSpecifiedType($scope, $node, null, $reasons); + } + + /** + * @param list $reasons + */ + private function doFindSpecifiedType( + Scope $scope, + Expr $node, + ?ExpressionResult $nodeResult, + array &$reasons, + ): ?bool + { + $specifiedValue = $this->getSpecifiedType($scope, $node, $reasons, $nodeResult); $reasons = array_values(array_unique($reasons)); /** @@ -98,6 +129,7 @@ private function getSpecifiedType( Scope $scope, Expr $node, array &$reasons = [], + ?ExpressionResult $nodeResult = null, ): ?bool { if ($node instanceof FuncCall) { @@ -315,7 +347,13 @@ 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 (carried by the virtual node) + // instead of asking the scope to specify it before the call is processed. + $specifiedTypes = ($nodeResult !== null + ? $nodeResult->getSpecifiedTypesForScope($typeSpecifierScope, $typeSpecifierContext) + : $typeSpecifierScope->specifyTypesOfNewWorldHandlerNode($node, $typeSpecifierContext)); // don't validate types on overwrite if ($specifiedTypes->shouldOverwrite()) { @@ -505,7 +543,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 a5b226387dd..05d5a76daf2 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\MethodCallExpressionNode; use PHPStan\Parser\LastConditionVisitor; use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\Rule; @@ -17,7 +18,7 @@ use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class ImpossibleCheckTypeMethodCallRule implements Rule @@ -40,76 +41,78 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\MethodCall::class; + return MethodCallExpressionNode::class; } public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { - if (!$node->name instanceof Node\Identifier) { + $methodCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); + if (!$methodCall->name instanceof Node\Identifier) { return []; } - $methodName = $node->name->name; + $methodName = $methodCall->name->name; $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $methodCall, $nodeResult, $reasons); if ($isAlways === null) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $methodCall); return []; } - $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $node); + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $methodCall); - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $methodCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder->acceptsReasonsTip($reasons)); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } if (!$this->treatPhpDocTypesAsCertain) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $methodCall, $nodeResult); if ($isAlways !== null) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); } if (!$this->treatPhpDocTypesAsCertainTip) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); } $ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $methodCall, $ruleErrorBuilder); }; if (!$isAlways) { - $method = $this->getMethod($node->var, $methodName, $scope); + $method = $this->getMethod($methodCall->var, $methodName, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to method %s::%s()%s will always evaluate to false.', $method->getDeclaringClass()->getDisplayName(), $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $methodCall->getArgs()), ))); $ruleError = $errorBuilder->identifier('method.impossibleType')->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $methodCall, false, $ruleError); return []; } return [$ruleError]; } - $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + $isLast = $methodCall->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $methodCall); return []; } - $method = $this->getMethod($node->var, $methodName, $scope); + $method = $this->getMethod($methodCall->var, $methodName, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to method %s::%s()%s will always evaluate to true.', $method->getDeclaringClass()->getDisplayName(), $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $methodCall->getArgs()), ))); if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); @@ -119,7 +122,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $ruleError = $errorBuilder->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $methodCall, true, $ruleError); return []; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php index e1ecf70ecdd..874636456e8 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php @@ -9,6 +9,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\StaticMethodCallExpressionNode; use PHPStan\Parser\LastConditionVisitor; use PHPStan\Reflection\MethodReflection; use PHPStan\Rules\Rule; @@ -17,7 +18,7 @@ use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class ImpossibleCheckTypeStaticMethodCallRule implements Rule @@ -40,77 +41,79 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\StaticCall::class; + return StaticMethodCallExpressionNode::class; } public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { - if (!$node->name instanceof Node\Identifier) { + $staticCall = $node->getOriginalNode(); + $nodeResult = $node->getResult(); + if (!$staticCall->name instanceof Node\Identifier) { return []; } - $methodName = $node->name->name; + $methodName = $staticCall->name->name; $reasons = []; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, $reasons); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $staticCall, $nodeResult, $reasons); if ($isAlways === null) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $staticCall); return []; } - $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $node); + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $staticCall); - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $staticCall, $nodeResult, $reasons): RuleErrorBuilder { if ($reasons !== []) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder->acceptsReasonsTip($reasons)); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder->acceptsReasonsTip($reasons)); } if (!$this->treatPhpDocTypesAsCertain) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $staticCall, $nodeResult); if ($isAlways !== null) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); } if (!$this->treatPhpDocTypesAsCertainTip) { - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); } $ruleErrorBuilder = $ruleErrorBuilder->treatPhpDocTypesAsCertainTip(); - return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); + return $this->possiblyImpureTipHelper->addTip($scope, $staticCall, $ruleErrorBuilder); }; if (!$isAlways) { - $method = $this->getMethod($node->class, $methodName, $scope); + $method = $this->getMethod($staticCall->class, $methodName, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to static method %s::%s()%s will always evaluate to false.', $method->getDeclaringClass()->getDisplayName(), $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $staticCall->getArgs()), ))); $ruleError = $errorBuilder->identifier('staticMethod.impossibleType')->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, false, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $staticCall, false, $ruleError); return []; } return [$ruleError]; } - $isLast = $node->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); + $isLast = $staticCall->getAttribute(LastConditionVisitor::ATTRIBUTE_NAME); if ($isLast === true && !$this->reportAlwaysTrueInLastCondition) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node); + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $staticCall); return []; } - $method = $this->getMethod($node->class, $methodName, $scope); + $method = $this->getMethod($staticCall->class, $methodName, $scope); $errorBuilder = $addTip(RuleErrorBuilder::message(sprintf( 'Call to static method %s::%s()%s will always evaluate to true.', $method->getDeclaringClass()->getDisplayName(), $method->getName(), - $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $node->getArgs()), + $this->impossibleCheckTypeHelper->getArgumentsDescription($scope, $staticCall->getArgs()), ))); if ($isLast === false && !$this->reportAlwaysTrueInLastCondition) { $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); @@ -120,7 +123,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $ruleError = $errorBuilder->build(); if ($scope->isInTrait()) { - $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node, true, $ruleError); + $this->constantConditionInTraitHelper->emitError(self::class, $scope, $staticCall, true, $ruleError); return []; } diff --git a/src/Rules/Generators/YieldFromTypeRule.php b/src/Rules/Generators/YieldFromTypeRule.php index b77f04b53a4..a0102c5c9e1 100644 --- a/src/Rules/Generators/YieldFromTypeRule.php +++ b/src/Rules/Generators/YieldFromTypeRule.php @@ -137,7 +137,7 @@ public function processNode(Node $node, Scope $scope): array ))->identifier('generator.sendType')->build(); } - if (!$scope->isInFirstLevelStatement() && $scope->getType($node)->isVoid()->yes()) { + if (!$scope->isInFirstLevelStatement() && $scope->getKeepVoidType($node)->isVoid()->yes()) { $messages[] = RuleErrorBuilder::message('Result of yield from (void) is used.') ->identifier('generator.void') ->build(); diff --git a/src/Rules/Generators/YieldTypeRule.php b/src/Rules/Generators/YieldTypeRule.php index e5f94c81932..0f42eecd120 100644 --- a/src/Rules/Generators/YieldTypeRule.php +++ b/src/Rules/Generators/YieldTypeRule.php @@ -86,7 +86,7 @@ public function processNode(Node $node, Scope $scope): array ->identifier('generator.valueType') ->build(); } - if (!$scope->isInFirstLevelStatement() && $scope->getType($node)->isVoid()->yes()) { + if (!$scope->isInFirstLevelStatement() && $scope->getKeepVoidType($node)->isVoid()->yes()) { $messages[] = RuleErrorBuilder::message('Result of yield (void) is used.') ->identifier('generator.void') ->build(); diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php index 35195e7ea52..99cbcd00df2 100644 --- a/src/Rules/IssetCheck.php +++ b/src/Rules/IssetCheck.php @@ -2,22 +2,30 @@ namespace PHPStan\Rules; -use PhpParser\Node; -use PhpParser\Node\Expr; +use PhpParser\Node\Expr\NullsafePropertyFetch; +use PhpParser\Node\Identifier; +use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\IssetabilityResolution; +use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Node\Expr\PropertyInitializationExpr; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\VerbosityLevel; -use function is_string; +use function fwrite; +use function getenv; use function sprintf; use function str_starts_with; +use const STDERR; /** + * Renders the isset/empty/?? "does it make sense" errors from the single + * IssetabilityResolution the engine already computed. The chain is walked and + * resolved once (IssetabilityDescriptor::resolve); this only projects the + * resolved facts into messages - it never re-walks the AST nor re-resolves types. + * * @phpstan-type ErrorIdentifier = 'empty'|'isset'|'nullCoalesce' */ #[AutowiredService] @@ -26,7 +34,6 @@ final class IssetCheck public function __construct( private PropertyDescriptor $propertyDescriptor, - private PropertyReflectionFinder $propertyReflectionFinder, #[AutowiredParameter] private bool $checkAdvancedIsset, #[AutowiredParameter] @@ -39,26 +46,50 @@ public function __construct( * @param ErrorIdentifier $identifier * @param callable(Type): ?string $typeMessageCallback */ - public function check(Expr $expr, Scope $scope, string $operatorDescription, string $identifier, callable $typeMessageCallback, ?IdentifierRuleError $error = null): ?IdentifierRuleError + public function check(ExpressionResult $exprResult, Scope $scope, string $operatorDescription, string $identifier, callable $typeMessageCallback): ?IdentifierRuleError { - // mirrored in PHPStan\Analyser\MutatingScope::issetCheck() - if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { - $hasVariable = $scope->hasVariableType($expr->name); + $mutatingScope = $scope->toMutatingScope(); + $resolution = $exprResult->getIssetabilityResolution($mutatingScope, !$this->treatPhpDocTypesAsCertain); + + return $this->doCheck($resolution, $mutatingScope, $operatorDescription, $identifier, $typeMessageCallback, null); + } + + /** + * @param ErrorIdentifier $identifier + * @param callable(Type): ?string $typeMessageCallback + */ + private function doCheck(IssetabilityResolution $resolution, MutatingScope $scope, string $operatorDescription, string $identifier, callable $typeMessageCallback, ?IdentifierRuleError $error): ?IdentifierRuleError + { + $link = $resolution->getLink(); + $inner = $resolution->getInner(); + if (getenv('ISSET_DEBUG') !== false) { + $fetch = $link->isProperty() ? $link->getPropertyFetch() : null; + fwrite(STDERR, sprintf( + "LINK line=%d prop=%s hasExprTypeOfFetch=%s writable=%s\n", + $fetch !== null ? $fetch->getStartLine() : ($link->isVariable() ? -1 : -2), + $fetch !== null && $fetch->name instanceof Identifier ? $fetch->name->name : '?', + $link->isProperty() ? ($link->hasExpressionTypeOfFetch() ? 'Y' : 'N') : '-', + $link->isProperty() && $link->getPropertyReflection() !== null ? $link->getPropertyReflection()->getWritableType()->describe(VerbosityLevel::precise()) : '-', + )); + } + + if ($link->isVariable()) { + $hasVariable = $link->getHasVariable(); if ($hasVariable->maybe()) { return null; } if ($error === null) { if ($hasVariable->yes()) { - if ($expr->name === '_SESSION') { + if ($link->getVariableName() === '_SESSION') { return null; } - $type = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($expr) : $scope->getScopeNativeType($expr); + $type = $link->getValueType(); if (!$type instanceof NeverType) { return $this->generateError( $type, - sprintf('Variable $%s %s always exists and', $expr->name, $operatorDescription), + sprintf('Variable $%s %s always exists and', $link->getVariableName(), $operatorDescription), $typeMessageCallback, $identifier, 'variable', @@ -66,24 +97,22 @@ public function check(Expr $expr, Scope $scope, string $operatorDescription, str } } - return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $expr->name, $operatorDescription)) + return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $link->getVariableName(), $operatorDescription)) ->identifier(sprintf('%s.variable', $identifier)) ->build(); } return $error; - } elseif ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { - $type = $this->treatPhpDocTypesAsCertain - ? $scope->getScopeType($expr->var) - : $scope->getScopeNativeType($expr->var); - if (!$type->isOffsetAccessible()->yes()) { - return $error ?? $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + } + + if ($link->isOffset()) { + $type = $link->getVarType(); + if (!$link->getIsOffsetAccessible()->yes()) { + return $error ?? $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } - $dimType = $this->treatPhpDocTypesAsCertain - ? $scope->getScopeType($expr->dim) - : $scope->getScopeNativeType($expr->dim); - $hasOffsetValue = $type->hasOffsetValueType($dimType); + $dimType = $link->getDimType(); + $hasOffsetValue = $link->getHasOffsetValue(); if ($hasOffsetValue->no()) { if (!$this->checkAdvancedIsset) { return null; @@ -101,12 +130,12 @@ public function check(Expr $expr, Scope $scope, string $operatorDescription, str // 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() || $scope->hasExpressionType($expr)->yes()) { + if ($hasOffsetValue->yes() || $link->hasExpressionTypeOfExpr()) { if (!$this->checkAdvancedIsset) { return null; } - $error ??= $this->generateError($type->getOffsetValueType($dimType), sprintf( + $error ??= $this->generateError($link->getValueType(), sprintf( 'Offset %s on %s %s always exists and', $dimType->describe(VerbosityLevel::value()), $type->describe(VerbosityLevel::value()), @@ -114,56 +143,27 @@ public function check(Expr $expr, Scope $scope, string $operatorDescription, str ), $typeMessageCallback, $identifier, 'offset'); if ($error !== null) { - return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); + return $inner !== null ? $this->doCheck($inner, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error) : $error; } } // Has offset, it is nullable return null; + } - } elseif ($expr instanceof Node\Expr\PropertyFetch || $expr instanceof Node\Expr\StaticPropertyFetch) { - - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $scope->toMutatingScope()); - - if ($propertyReflection === null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); - } - - if ($expr->class instanceof Expr) { - return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); - } - - return null; - } - - if (!$propertyReflection->isNative()) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); - } - - if ($expr->class instanceof Expr) { - return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); - } + if ($link->isProperty()) { + $reflection = $link->getPropertyReflection(); + $propertyFetch = $link->getPropertyFetch(); - return null; + if ($reflection === null || !$link->isReflectionNative()) { + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } - if ($propertyReflection->hasNativeType() && !$propertyReflection->isVirtual()->yes()) { - if ( - $expr instanceof Node\Expr\PropertyFetch - && $expr->name instanceof Node\Identifier - && $expr->var instanceof Expr\Variable - && $expr->var->name === 'this' - && $scope->hasExpressionType(new PropertyInitializationExpr($propertyReflection->getName()))->yes() - ) { + if ($link->hasNativeType() && !$link->isVirtual()->yes()) { + if ($link->isInitializedThisProperty()) { return $this->generateError( - $propertyReflection->getNativeType(), - sprintf( - '%s %s', - $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $expr), - $operatorDescription, - ), + $link->getNativeType(), + sprintf('%s %s', $this->propertyDescriptor->describeProperty($reflection, $scope, $propertyFetch), $operatorDescription), static function (Type $type) use ($typeMessageCallback): ?string { $originalMessage = $typeMessageCallback($type); if ($originalMessage === null) { @@ -181,64 +181,43 @@ static function (Type $type) use ($typeMessageCallback): ?string { ); } - if (!$scope->hasExpressionType($expr)->yes()) { - $nativeReflection = $propertyReflection->getNativeReflection(); - if ( - $nativeReflection !== null - && !$nativeReflection->getNativeReflection()->hasDefaultValue() - && (!$nativeReflection->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) - ) { - return null; - } + if ( + !$link->hasExpressionTypeOfFetch() + && $link->nativeReflectionExists() + && !$link->nativeHasDefaultValue() + && (!$link->nativeIsPromoted() || (!$link->nativeIsReadOnly() && !$link->nativeIsHooked())) + ) { + return null; } } - $propertyDescription = $this->propertyDescriptor->describeProperty($propertyReflection, $scope, $expr); - $propertyType = $propertyReflection->getWritableType(); + $propertyDescription = $this->propertyDescriptor->describeProperty($reflection, $scope, $propertyFetch); + $propertyType = $reflection->getWritableType(); if ($error !== null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); - } - - if ($expr->class instanceof Expr) { - return $this->check($expr->class, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); - } - - return $error; + return $inner !== null + ? $this->doCheck($inner, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error) + : $error; } if (!$this->checkAdvancedIsset) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); - } - - if ($expr->class instanceof Expr) { - return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); - } - - return null; + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } $error = $this->generateError( - $propertyReflection->getWritableType(), + $propertyType, sprintf('%s (%s) %s', $propertyDescription, $propertyType->describe(VerbosityLevel::typeOnly()), $operatorDescription), $typeMessageCallback, $identifier, 'property', ); - if ($error !== null) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->check($expr->var, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); - } - - if ($expr->class instanceof Expr) { - return $this->check($expr->class, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); - } + if ($error !== null && $inner !== null) { + return $this->doCheck($inner, $scope, $operatorDescription, $identifier, $typeMessageCallback, $error); } return $error; } + // leaf - an arbitrary base expression that is not a chain link if ($error !== null) { return $error; } @@ -248,7 +227,7 @@ static function (Type $type) use ($typeMessageCallback): ?string { } $error = $this->generateError( - $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($expr) : $scope->getScopeNativeType($expr), + $link->getValueType(), sprintf('Expression %s', $operatorDescription), $typeMessageCallback, $identifier, @@ -258,9 +237,10 @@ static function (Type $type) use ($typeMessageCallback): ?string { return $error; } - if ($expr instanceof Expr\NullsafePropertyFetch) { - if ($expr->name instanceof Node\Identifier) { - return RuleErrorBuilder::message(sprintf('Using nullsafe property access "?->%s" %s is unnecessary. Use -> instead.', $expr->name->name, $operatorDescription)) + if ($link->leafIsNullsafePropertyFetch()) { + $leafExpr = $link->getLeafExpr(); + if ($leafExpr instanceof NullsafePropertyFetch && $leafExpr->name instanceof Identifier) { + return RuleErrorBuilder::message(sprintf('Using nullsafe property access "?->%s" %s is unnecessary. Use -> instead.', $leafExpr->name->name, $operatorDescription)) ->identifier('nullsafe.neverNull') ->build(); } @@ -273,50 +253,46 @@ static function (Type $type) use ($typeMessageCallback): ?string { return null; } - /** - * @param ErrorIdentifier $identifier - */ - private function checkUndefined(Expr $expr, Scope $scope, string $operatorDescription, string $identifier): ?IdentifierRuleError + private function checkUndefinedInner(?IssetabilityResolution $resolution, MutatingScope $scope, string $operatorDescription, string $identifier): ?IdentifierRuleError { - if ($expr instanceof Node\Expr\Variable && is_string($expr->name)) { - $hasVariable = $scope->hasVariableType($expr->name); - if (!$hasVariable->no()) { + if ($resolution === null) { + return null; + } + + $link = $resolution->getLink(); + $inner = $resolution->getInner(); + + if ($link->isVariable()) { + if (!$link->getHasVariable()->no()) { return null; } - return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $expr->name, $operatorDescription)) + return RuleErrorBuilder::message(sprintf('Variable $%s %s is never defined.', $link->getVariableName(), $operatorDescription)) ->identifier(sprintf('%s.variable', $identifier)) ->build(); } - if ($expr instanceof Node\Expr\ArrayDimFetch && $expr->dim !== null) { - $type = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($expr->var) : $scope->getScopeNativeType($expr->var); - $dimType = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($expr->dim) : $scope->getScopeNativeType($expr->dim); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - if (!$type->isOffsetAccessible()->yes()) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + if ($link->isOffset()) { + if (!$link->getIsOffsetAccessible()->yes()) { + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } - if (!$hasOffsetValue->no()) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); + if (!$link->getHasOffsetValue()->no()) { + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } return RuleErrorBuilder::message( sprintf( 'Offset %s on %s %s does not exist.', - $dimType->describe(VerbosityLevel::value()), - $type->describe(VerbosityLevel::value()), + $link->getDimType()->describe(VerbosityLevel::value()), + $link->getVarType()->describe(VerbosityLevel::value()), $operatorDescription, ), )->identifier(sprintf('%s.offset', $identifier))->build(); } - if ($expr instanceof Expr\PropertyFetch) { - return $this->checkUndefined($expr->var, $scope, $operatorDescription, $identifier); - } - - if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { - return $this->checkUndefined($expr->class, $scope, $operatorDescription, $identifier); + if ($link->isProperty()) { + return $this->checkUndefinedInner($inner, $scope, $operatorDescription, $identifier); } return null; diff --git a/src/Rules/Methods/NullsafeMethodCallRule.php b/src/Rules/Methods/NullsafeMethodCallRule.php index a3d3b872f73..e77fa140bec 100644 --- a/src/Rules/Methods/NullsafeMethodCallRule.php +++ b/src/Rules/Methods/NullsafeMethodCallRule.php @@ -6,13 +6,14 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\NullsafeMethodCallExpressionNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\VerbosityLevel; use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class NullsafeMethodCallRule implements Rule @@ -29,22 +30,23 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\NullsafeMethodCall::class; + return NullsafeMethodCallExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { - $calledOnType = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($node->var) : $scope->getScopeNativeType($node->var); + $originalNode = $node->getOriginalNode(); + $calledOnType = $this->treatPhpDocTypesAsCertain ? $node->getCalledOnType() : $node->getCalledOnNativeType(); if (!$calledOnType->isNull()->no()) { return []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($node): RuleErrorBuilder { if (!$this->treatPhpDocTypesAsCertain || !$this->treatPhpDocTypesAsCertainTip) { return $ruleErrorBuilder; } - $calledOnNativeType = $scope->getScopeNativeType($node->var); + $calledOnNativeType = $node->getCalledOnNativeType(); if ($calledOnNativeType->isNull()->no()) { return $ruleErrorBuilder; } @@ -58,7 +60,7 @@ public function processNode(Node $node, Scope $scope): array $calledOnType->describe(VerbosityLevel::typeOnly()), )), ) - ->line($node->name->getStartLine()) + ->line($originalNode->name->getStartLine()) ->identifier('nullsafe.neverNull'); return [$ruleErrorBuilder->build()]; diff --git a/src/Rules/Properties/NullsafePropertyFetchRule.php b/src/Rules/Properties/NullsafePropertyFetchRule.php index 1e11e03f8a8..debad865a30 100644 --- a/src/Rules/Properties/NullsafePropertyFetchRule.php +++ b/src/Rules/Properties/NullsafePropertyFetchRule.php @@ -6,13 +6,14 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\NullsafePropertyFetchExpressionNode; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\VerbosityLevel; use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 4)] final class NullsafePropertyFetchRule implements Rule @@ -29,26 +30,27 @@ public function __construct( public function getNodeType(): string { - return Node\Expr\NullsafePropertyFetch::class; + return NullsafePropertyFetchExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { - $calledOnType = $this->treatPhpDocTypesAsCertain ? $scope->getScopeType($node->var) : $scope->getScopeNativeType($node->var); + $originalNode = $node->getOriginalNode(); + $calledOnType = $this->treatPhpDocTypesAsCertain ? $node->getCalledOnType() : $node->getCalledOnNativeType(); if (!$calledOnType->isNull()->no()) { return []; } - if ($scope->isUndefinedExpressionAllowed($node)) { + if ($scope->isUndefinedExpressionAllowed($originalNode)) { return []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { + $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($node): RuleErrorBuilder { if (!$this->treatPhpDocTypesAsCertain || !$this->treatPhpDocTypesAsCertainTip) { return $ruleErrorBuilder; } - $calledOnNativeType = $scope->getScopeNativeType($node->var); + $calledOnNativeType = $node->getCalledOnNativeType(); if ($calledOnNativeType->isNull()->no()) { return $ruleErrorBuilder; } @@ -62,7 +64,7 @@ public function processNode(Node $node, Scope $scope): array $calledOnType->describe(VerbosityLevel::typeOnly()), )), ) - ->line($node->name->getStartLine()) + ->line($originalNode->name->getStartLine()) ->identifier('nullsafe.neverNull'); return [$ruleErrorBuilder->build()]; 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/src/Rules/Variables/EmptyRule.php b/src/Rules/Variables/EmptyRule.php index d1656a3be27..b884cebddc3 100644 --- a/src/Rules/Variables/EmptyRule.php +++ b/src/Rules/Variables/EmptyRule.php @@ -5,12 +5,13 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\EmptyExpressionNode; use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Rule; use PHPStan\Type\Type; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 1)] final class EmptyRule implements Rule @@ -22,12 +23,12 @@ public function __construct(private IssetCheck $issetCheck) public function getNodeType(): string { - return Node\Expr\Empty_::class; + return EmptyExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { - $error = $this->issetCheck->check($node->expr, $scope, 'in empty()', 'empty', static function (Type $type): ?string { + $error = $this->issetCheck->check($node->getExprResult(), $scope, 'in empty()', 'empty', static function (Type $type): ?string { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; diff --git a/src/Rules/Variables/IssetRule.php b/src/Rules/Variables/IssetRule.php index 839241a169b..cd9300c2dc5 100644 --- a/src/Rules/Variables/IssetRule.php +++ b/src/Rules/Variables/IssetRule.php @@ -5,12 +5,13 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\IssetExpressionNode; use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Rule; use PHPStan\Type\Type; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 1)] final class IssetRule implements Rule @@ -22,14 +23,14 @@ public function __construct(private IssetCheck $issetCheck) public function getNodeType(): string { - return Node\Expr\Isset_::class; + return IssetExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { $messages = []; - foreach ($node->vars as $var) { - $error = $this->issetCheck->check($var, $scope, 'in isset()', 'isset', static function (Type $type): ?string { + foreach ($node->getVarResults() as $varResult) { + $error = $this->issetCheck->check($varResult, $scope, 'in isset()', 'isset', static function (Type $type): ?string { $isNull = $type->isNull(); if ($isNull->maybe()) { return null; diff --git a/src/Rules/Variables/NullCoalesceRule.php b/src/Rules/Variables/NullCoalesceRule.php index 6250b59b906..409ae4e431d 100644 --- a/src/Rules/Variables/NullCoalesceRule.php +++ b/src/Rules/Variables/NullCoalesceRule.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\CoalesceExpressionNode; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Rule; @@ -14,7 +15,7 @@ use function sprintf; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 1)] final class NullCoalesceRule implements Rule @@ -30,42 +31,35 @@ public function __construct( public function getNodeType(): string { - return Node\Expr::class; + return CoalesceExpressionNode::class; } public function processNode(Node $node, Scope $scope): array { - $typeMessageCallback = static function (Type $type): ?string { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } - - if ($isNull->yes()) { - return 'is always null'; - } - - return 'is not nullable'; - }; - - if ($node instanceof Node\Expr\BinaryOp\Coalesce) { - $left = $node->left; - $right = $node->right; - $operator = '??'; - } elseif ($node instanceof Node\Expr\AssignOp\Coalesce) { - $left = $node->var; - $right = $node->expr; - $operator = '??='; - } else { - return []; - } + $error = $this->issetCheck->check( + $node->getSubjectResult(), + $scope, + $node->getOperatorDescription(), + 'nullCoalesce', + static function (Type $type): ?string { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + if ($isNull->yes()) { + return 'is always null'; + } + + return 'is not nullable'; + }, + ); - $error = $this->issetCheck->check($left, $scope, sprintf('on left side of %s', $operator), 'nullCoalesce', $typeMessageCallback); if ($error !== null) { return [$error]; } - $unnecessaryError = $this->checkUnnecessaryNullCoalesce($left, $right, $operator, $scope); + $unnecessaryError = $this->checkUnnecessaryNullCoalesce($node, $scope); if ($unnecessaryError !== null) { return [$unnecessaryError]; } @@ -73,12 +67,23 @@ public function processNode(Node $node, Scope $scope): array return []; } - private function checkUnnecessaryNullCoalesce(Node\Expr $left, Node\Expr $right, string $operator, Scope $scope): ?IdentifierRuleError + private function checkUnnecessaryNullCoalesce(CoalesceExpressionNode $node, Scope $scope): ?IdentifierRuleError { if (!$this->unnecessaryNullCoalesce) { return null; } + $originalExpr = $node->getOriginalExpr(); + if ($originalExpr instanceof Node\Expr\BinaryOp\Coalesce) { + $right = $originalExpr->right; + $operator = '??'; + } elseif ($originalExpr instanceof Node\Expr\AssignOp\Coalesce) { + $right = $originalExpr->expr; + $operator = '??='; + } else { + return null; + } + if (!$scope->getType($right)->isNull()->yes()) { return null; } @@ -86,7 +91,8 @@ private function checkUnnecessaryNullCoalesce(Node\Expr $left, Node\Expr $right, // The coalesce only changes the result when the left side is undefined. // If the left side is always set, `?? null` (or `??= null`) never changes // anything, so the whole coalesce is redundant. - if ($scope->toMutatingScope()->issetCheck($left, static fn (): bool => true) !== true) { + $resolution = $node->getSubjectResult()->getIssetabilityResolution($scope->toMutatingScope(), false); + if ($resolution->isSet(static fn (): bool => true) !== true) { return null; } diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 04f550f51ba..bd6cf503410 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 13e9a907dab..09c24c7f0a1 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/src/Turbo/TurboExtensionEnabler.php b/src/Turbo/TurboExtensionEnabler.php index adeb5659659..a2c2d09b424 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 = '86312b7'; + public const EXPECTED_EXTENSION_VERSION = 'e07981a'; private static bool $typeCombinatorCacheEnabled = false; diff --git a/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php b/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php new file mode 100644 index 00000000000..cbad3124f83 --- /dev/null +++ b/src/Type/Php/ArrayFilterParameterClosureTypeExtension.php @@ -0,0 +1,62 @@ +getName() === 'array_filter' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + $parameters = null; + if (isset($args[2])) { + $mode = $scope->getType($args[2]->value); + if ($mode instanceof ConstantIntegerType) { + if ($mode->getValue() === ARRAY_FILTER_USE_KEY) { + $parameters = [$this->createParameter('key', $scope->getIterableKeyType($arrayType))]; + } elseif ($mode->getValue() === ARRAY_FILTER_USE_BOTH) { + $parameters = [ + $this->createParameter('item', $scope->getIterableValueType($arrayType)), + $this->createParameter('key', $scope->getIterableKeyType($arrayType)), + ]; + } + } + } + + $parameters ??= [$this->createParameter('item', $scope->getIterableValueType($arrayType))]; + + return new ClosureType($parameters, new BooleanType()); + } + + private function createParameter(string $name, Type $type): NativeParameterReflection + { + return new NativeParameterReflection($name, false, $type, PassedByReference::createNo(), false, null); + } + +} diff --git a/src/Type/Php/ArrayFindParameterClosureTypeExtension.php b/src/Type/Php/ArrayFindParameterClosureTypeExtension.php new file mode 100644 index 00000000000..e99a0e87368 --- /dev/null +++ b/src/Type/Php/ArrayFindParameterClosureTypeExtension.php @@ -0,0 +1,43 @@ +getName(), ['array_find', 'array_find_key', 'array_any', 'array_all'], true) + && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + + return new ClosureType([ + new NativeParameterReflection('value', false, $scope->getIterableValueType($arrayType), PassedByReference::createNo(), false, null), + new NativeParameterReflection('key', false, $scope->getIterableKeyType($arrayType), PassedByReference::createNo(), false, null), + ], new BooleanType()); + } + +} diff --git a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php index 75766e35fd9..a9766d7ceba 100644 --- a/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayMapFunctionReturnTypeExtension.php @@ -172,8 +172,9 @@ private static function resolveCallbackReturnType(Scope $scope, Node\Expr $callb [$argType], isList: TrinaryLogic::createYes(), ); + // the resolved-closure-type cache is keyed by node identity + // (ClosureTypeResolver::$cachedTypes), so the clone starts uncached $clone->setAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME, [new Node\Arg(new TypeExpr($wrappedType))]); - $clone->setAttribute('phpstanCachedTypes', []); return $scope->getType($clone)->getCallableParametersAcceptors($scope)[0]->getReturnType(); } diff --git a/src/Type/Php/ArrayMapParameterClosureTypeExtension.php b/src/Type/Php/ArrayMapParameterClosureTypeExtension.php new file mode 100644 index 00000000000..6ae1c8198a0 --- /dev/null +++ b/src/Type/Php/ArrayMapParameterClosureTypeExtension.php @@ -0,0 +1,62 @@ +getName() === 'array_map' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (count($args) < 2) { + return null; + } + + $callbackParameters = []; + $argCount = count($args); + for ($i = 1; $i < $argCount; $i++) { + $arg = $args[$i]; + $arrayType = $scope->getType($arg->value); + if ($arg->unpack) { + $constantArrays = $arrayType->getConstantArrays(); + if (count($constantArrays) === 0) { + return null; + } + foreach ($constantArrays as $constantArray) { + foreach ($constantArray->getValueTypes() as $valueType) { + $callbackParameters[] = $this->createParameter($scope->getIterableValueType($valueType)); + } + } + } else { + $callbackParameters[] = $this->createParameter($scope->getIterableValueType($arrayType)); + } + } + + return new ClosureType($callbackParameters, new MixedType()); + } + + private function createParameter(Type $type): NativeParameterReflection + { + return new NativeParameterReflection('item', false, $type, PassedByReference::createNo(), false, null); + } + +} diff --git a/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php b/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php new file mode 100644 index 00000000000..4ddf25ab94f --- /dev/null +++ b/src/Type/Php/ArrayWalkParameterClosureTypeExtension.php @@ -0,0 +1,45 @@ +getName() === 'array_walk' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + if (!isset($args[0])) { + return null; + } + + $arrayType = $scope->getType($args[0]->value); + $parameters = [ + new NativeParameterReflection('item', false, $scope->getIterableValueType($arrayType), PassedByReference::createReadsArgument(), false, null), + new NativeParameterReflection('key', false, $scope->getIterableKeyType($arrayType), PassedByReference::createNo(), false, null), + ]; + if (isset($args[2])) { + $parameters[] = new NativeParameterReflection('arg', false, $scope->getType($args[2]->value), PassedByReference::createNo(), false, null); + } + + return new ClosureType($parameters, new MixedType()); + } + +} diff --git a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php index fdc77b5e4a5..a6b7c18118d 100644 --- a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php @@ -4,8 +4,6 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; -use PHPStan\Analyser\TypeSpecifier; -use PHPStan\Analyser\TypeSpecifierAwareExtension; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Reflection\FunctionReflection; @@ -18,11 +16,9 @@ use function in_array; #[AutowiredService] -final class TypeSpecifyingFunctionsDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension, TypeSpecifierAwareExtension +final class TypeSpecifyingFunctionsDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - private TypeSpecifier $typeSpecifier; - private ?ImpossibleCheckTypeHelper $helper = null; public function __construct( @@ -33,11 +29,6 @@ public function __construct( { } - public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void - { - $this->typeSpecifier = $typeSpecifier; - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return in_array($functionReflection->getName(), [ @@ -58,7 +49,7 @@ public function getTypeFromFunctionCall( return null; } - $isAlways = $this->getHelper()->findSpecifiedType( + $isAlways = $this->getHelper()->findSpecifiedTypeFromScope( $scope, $functionCall, ); @@ -71,7 +62,7 @@ public function getTypeFromFunctionCall( private function getHelper(): ImpossibleCheckTypeHelper { - return $this->helper ??= new ImpossibleCheckTypeHelper($this->reflectionProvider, $this->typeSpecifier, $this->treatPhpDocTypesAsCertain); + return $this->helper ??= new ImpossibleCheckTypeHelper($this->reflectionProvider, $this->treatPhpDocTypesAsCertain); } } diff --git a/tests/PHPStan/Analyser/AnalyserTest.php b/tests/PHPStan/Analyser/AnalyserTest.php index 04417b7ab9f..4f0de3f91dc 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -40,12 +40,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 { @@ -816,7 +818,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), @@ -829,7 +836,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 c9732b873d2..173c4d77428 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 00038bbc18e..5757fc4583b 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/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index 0d011b0b0b9..20567b364fe 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -655,7 +655,7 @@ public static function dataCondition(): iterable [ new Expr\Empty_(new Variable('array')), [ - '$array' => 'array{}', + '$array' => '~mixed~(0|0.0|\'\'|\'0\'|array{}|false|null)', ], [ '$array' => '~0|0.0|\'\'|\'0\'|array{}|false|null', @@ -667,7 +667,7 @@ public static function dataCondition(): iterable '$array' => '~0|0.0|\'\'|\'0\'|array{}|false|null', ], [ - '$array' => 'array{}', + '$array' => '~mixed~(0|0.0|\'\'|\'0\'|array{}|false|null)', ], ], [ @@ -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,13 +1391,9 @@ 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 filterBySpecifiedTypes() evaluates it at the application + // same way applySpecifiedTypes() evaluates it at the application // point - the readable result matches the old eager normalize form $parts = []; foreach ($terms as [$sure, $subtract]) { @@ -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/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/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 @@ + $this->id, + $foo, + Foo::class, +); + +assertType('((Closure(): int)|null)', $closure); diff --git a/tests/PHPStan/Analyser/nsrt/bug-12207.php b/tests/PHPStan/Analyser/nsrt/bug-12207.php new file mode 100644 index 00000000000..63990d759c4 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12207.php @@ -0,0 +1,31 @@ +}> + */ + 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-13789.php b/tests/PHPStan/Analyser/nsrt/bug-13789.php new file mode 100644 index 00000000000..686b7f96e4c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13789.php @@ -0,0 +1,28 @@ +> */ +function get_list_of_non_empty_array(): array +{ + throw new \Exception(); +} + +/** @param array $row */ +function sanitize(array &$row): void { } + + +$foo = get_list_of_non_empty_array(); +assertType('list>', $foo); + +foreach ($foo as &$row) { + sanitize($row); // $row might be empty after that line + assertType('array', $row); + $row[random_bytes(2)] = random_bytes(2); // $row is definitely non-empty after that line + assertType('non-empty-array', $row); +} +unset($row); + +assertType('list>', $foo); diff --git a/tests/PHPStan/Analyser/nsrt/bug-13802.php b/tests/PHPStan/Analyser/nsrt/bug-13802.php new file mode 100644 index 00000000000..94ed2adc429 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13802.php @@ -0,0 +1,26 @@ + + */ +function createArray(): array { + return ['arr' => 'key']; +} + +$arr = createArray(); + +assertType('array', $arr); + +foreach ($arr as &$val) { + assertType('string', $val); + $val = preg_replace('/[^\x20-\x7E]/', '', $val); + assertType('string|null', $val); + $val = str_replace(' ', '', $val ?? ''); + assertType('string', $val); +} + +assertType('array', $arr); 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-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/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 @@ +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/equality-narrowing-new-world.php b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php new file mode 100644 index 00000000000..29914710ad1 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/equality-narrowing-new-world.php @@ -0,0 +1,541 @@ += 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); + } + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/falsey-empty-certainty.php b/tests/PHPStan/Analyser/nsrt/falsey-empty-certainty.php index ba24b227307..7c6bd5fe97e 100644 --- a/tests/PHPStan/Analyser/nsrt/falsey-empty-certainty.php +++ b/tests/PHPStan/Analyser/nsrt/falsey-empty-certainty.php @@ -64,7 +64,8 @@ function maybeEmpty(): void if (!empty($foo)) { assertVariableCertainty(TrinaryLogic::createYes(), $foo); } else { - assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + // $foo is 1 when defined, so empty($foo) can only be true when it is undefined + assertVariableCertainty(TrinaryLogic::createNo(), $foo); } assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); } @@ -81,9 +82,10 @@ function maybeEmptyUnset(): void unset($foo); assertVariableCertainty(TrinaryLogic::createNo(), $foo); } else { - assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + // $foo is 1 when defined, so empty($foo) can only be true when it is undefined + assertVariableCertainty(TrinaryLogic::createNo(), $foo); } - assertVariableCertainty(TrinaryLogic::createMaybe(), $foo); + assertVariableCertainty(TrinaryLogic::createNo(), $foo); } 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/isset-property-default-value.php b/tests/PHPStan/Analyser/nsrt/isset-property-default-value.php index 0fc19bb8f6e..568f250b953 100644 --- a/tests/PHPStan/Analyser/nsrt/isset-property-default-value.php +++ b/tests/PHPStan/Analyser/nsrt/isset-property-default-value.php @@ -28,5 +28,8 @@ function coalesceWithoutDefault(Holder $a, Holder $b): void throw new \LogicException(); } - assertType('int|null', $a->noDefault ?? $b->noDefault); + // narrower than 2.2.x (int|null): the === null reads in the guard would + // throw on an uninitialized property, so past the guard both properties + // are initialized and at least one is non-null + assertType('int', $a->noDefault ?? $b->noDefault); } 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/Analyser/nsrt/myers-diff-loop-widening.php b/tests/PHPStan/Analyser/nsrt/myers-diff-loop-widening.php index a0a3c90786c..c05c1e46f06 100644 --- a/tests/PHPStan/Analyser/nsrt/myers-diff-loop-widening.php +++ b/tests/PHPStan/Analyser/nsrt/myers-diff-loop-widening.php @@ -44,8 +44,8 @@ public function calculateTrace(array $old, array $new): array $y++; } - assertType('(float|int)', $x); - assertType('(float|int)', $y); + assertType('int<0, max>', $x); + assertType('int<0, max>', $y); $v[$k] = $x; if ($x >= $n && $y >= $m) { 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 @@ + + */ +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 ''; + } +} 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 96b20b07bd1..72047361970 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 4bbb387ecbb..0ed02308e26 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), @@ -1391,4 +1390,10 @@ public function testBug6211(): void ]); } + public function testBug14908(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-14908.php'], []); + } + } 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 844d145d8a2..35a91c5a751 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/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index de35e7711eb..63f4e1efb04 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1280,4 +1280,9 @@ public function testBug14847(): void ]); } + public function testBug14908(): void + { + $this->analyse([__DIR__ . '/data/bug-14908.php'], []); + } + } 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), diff --git a/tests/PHPStan/Rules/Comparison/data/bug-14908.php b/tests/PHPStan/Rules/Comparison/data/bug-14908.php new file mode 100644 index 00000000000..4cb9727df34 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-14908.php @@ -0,0 +1,44 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14908; + +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(); + } + + if ($grade !== Grade::Three) { + if ($flags->flagA === false) { + throw new \Exception(); + } + if (in_array($kind, [Kind::K1, Kind::K2], true)) { + echo "reachable"; + } + } +} 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, + }; +} diff --git a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php index 23093600316..4ab72a3893c 100644 --- a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php @@ -77,4 +77,9 @@ public function testBugFunctionMethodConstants(): void $this->analyse([__DIR__ . '/data/bug-anonymous-function-method-constant.php'], []); } + public function testBug14914(): void + { + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); + } + } 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/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 44bc0a56d08..398c4df35e9 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, diff --git a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php index c363c194d36..3a9832d5931 100644 --- a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php @@ -149,4 +149,9 @@ public function testBug13964(): void $this->analyse([__DIR__ . '/data/bug-13964.php'], []); } + public function testBug14914(): void + { + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-14914.php b/tests/PHPStan/Rules/Functions/data/bug-14914.php new file mode 100644 index 00000000000..5bc6a7edb6a --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-14914.php @@ -0,0 +1,30 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14914; + +function doFoo(): void +{ + preg_replace_callback( + '/a|(?b)/', + function (array $match): string { + if ($match['b'] !== null) { + return 'aa'; + } + return 'possible?'; + }, + 'abcd', + 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, + ); +} diff --git a/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php b/tests/PHPStan/Rules/PhpDoc/WrongVariableNameInVarTagRuleTest.php index 6b63dd6a790..bd0e2b3463a 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, ], ]); diff --git a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php index 582fdeb1076..079b032e8ee 100644 --- a/tests/PHPStan/Rules/Variables/EmptyRuleTest.php +++ b/tests/PHPStan/Rules/Variables/EmptyRuleTest.php @@ -4,7 +4,6 @@ use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\DataProvider; @@ -22,7 +21,6 @@ protected function getRule(): Rule { return new EmptyRule(new IssetCheck( new PropertyDescriptor(), - new PropertyReflectionFinder(), true, $this->treatPhpDocTypesAsCertain, )); diff --git a/tests/PHPStan/Rules/Variables/IssetRuleTest.php b/tests/PHPStan/Rules/Variables/IssetRuleTest.php index 7e83c53117b..af8f1542e04 100644 --- a/tests/PHPStan/Rules/Variables/IssetRuleTest.php +++ b/tests/PHPStan/Rules/Variables/IssetRuleTest.php @@ -4,7 +4,6 @@ use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -21,7 +20,6 @@ protected function getRule(): Rule { return new IssetRule(new IssetCheck( new PropertyDescriptor(), - new PropertyReflectionFinder(), true, $this->treatPhpDocTypesAsCertain, )); diff --git a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php index fc6c577822d..479ef6b6139 100644 --- a/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php +++ b/tests/PHPStan/Rules/Variables/NullCoalesceRuleTest.php @@ -4,7 +4,6 @@ use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Properties\PropertyDescriptor; -use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\Rules\Rule; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; @@ -20,7 +19,6 @@ protected function getRule(): Rule { return new NullCoalesceRule(new IssetCheck( new PropertyDescriptor(), - new PropertyReflectionFinder(), true, $this->shouldTreatPhpDocTypesAsCertain(), ), true); @@ -560,4 +558,9 @@ public function testBug14393(): 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']++; + + } + } + +} 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/src/ExpressionTypeHolder.cpp b/turbo-ext/src/ExpressionTypeHolder.cpp index 14dc4823a5d..6aa1dead056 100644 --- a/turbo-ext/src/ExpressionTypeHolder.cpp +++ b/turbo-ext/src/ExpressionTypeHolder.cpp @@ -62,6 +62,112 @@ class ExpressionTypeHolder return zv::Val::adopt(result); } + /* + * Mirrors ExpressionTypeHolder::getContainedNodeKeys(): lazily indexes + * the node keys of every sub-expression of the holder's expr, keyed to + * the classes it appears as. The walk replicates the twin's explicit + * stack (LIFO pop order) so the cached array is key-order identical. + * UNDEF = pending exception. + */ + zv::Val getContainedNodeKeys(zend_fcall_info *fci, zend_fcall_info_cache *fcc) + { + zv::ObjRef obj(self); + zval *cached = obj.propAt(PT_ETH_PROP_CONTAINED_NODE_KEYS).raw(); + if (Z_TYPE_P(cached) == IS_ARRAY) { + return zv::Val::copyOf(zv::Ref(cached)); + } + + zend_class_entry *exprCe = pt_class(PT_CLASS_EXPR); + zend_class_entry *nodeCe = pt_class(PT_CLASS_NODE); + if (UNEXPECTED(exprCe == NULL || nodeCe == NULL)) { + return zv::Val(); + } + + zv::Arr keys = zv::Arr::create(8); + + /* borrowed node pointers — pinned by the expr tree the holder owns */ + uint32_t cap = 32, top = 0; + zend_object **stack = (zend_object **) emalloc(sizeof(*stack) * cap); + stack[top++] = obj.propAt(PT_ETH_PROP_EXPR).deref().asObject(); + + bool failed = false; + while (top > 0) { + zend_object *node = stack[--top]; + + if (instanceof_function(node->ce, exprCe)) { + /* $keys[$keyBuilder($node)][get_class($node)] = true */ + zval arg, retval; + ZVAL_OBJ(&arg, node); + fci->retval = &retval; + fci->param_count = 1; + fci->params = &arg; + fci->named_params = NULL; + if (UNEXPECTED(zend_call_function(fci, fcc) != SUCCESS || EG(exception))) { + failed = true; + break; + } + if (UNEXPECTED(Z_TYPE(retval) != IS_STRING)) { + zval_ptr_dtor(&retval); + zend_throw_error(NULL, "phpstan_turbo: getContainedNodeKeys() keyBuilder must return a string"); + failed = true; + break; + } + zval *innerSlot = zend_symtable_find(keys.table(), Z_STR(retval)); + if (innerSlot == NULL) { + zval innerZv; + array_init(&innerZv); + innerSlot = zend_symtable_update(keys.table(), Z_STR(retval), &innerZv); + } + zval trueZv; + ZVAL_TRUE(&trueZv); + zend_hash_update(Z_ARRVAL_P(innerSlot), node->ce->name, &trueZv); + zval_ptr_dtor(&retval); + } + + pt_node_class_info *info = pt_node_class_info_for_object(node); + if (info == NULL || !PT_HAS_SUBNODES(info)) { + continue; + } + for (uint32_t i = 0; i < info->subnode_count; i++) { + zval *val = OBJ_PROP(node, info->subnode_offsets[i]); + ZVAL_DEREF(val); + if (Z_TYPE_P(val) == IS_OBJECT) { + if (instanceof_function(Z_OBJCE_P(val), nodeCe)) { + if (UNEXPECTED(top == cap)) { + cap *= 2; + stack = (zend_object **) erealloc(stack, sizeof(*stack) * cap); + } + stack[top++] = Z_OBJ_P(val); + } + } else if (Z_TYPE_P(val) == IS_ARRAY) { + zval *el; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(val), el) { + zval *elDeref = el; + ZVAL_DEREF(elDeref); + if (Z_TYPE_P(elDeref) == IS_OBJECT && instanceof_function(Z_OBJCE_P(elDeref), nodeCe)) { + if (UNEXPECTED(top == cap)) { + cap *= 2; + stack = (zend_object **) erealloc(stack, sizeof(*stack) * cap); + } + stack[top++] = Z_OBJ_P(elDeref); + } + } ZEND_HASH_FOREACH_END(); + } + } + } + efree(stack); + + if (UNEXPECTED(failed)) { + return zv::Val(); + } + + /* $this->containedNodeKeys = $keys (cache), then return it */ + zval *slot = obj.propAt(PT_ETH_PROP_CONTAINED_NODE_KEYS).raw(); + zval_ptr_dtor(slot); + ZVAL_COPY(slot, keys.raw()); + return zv::Val(std::move(keys)); + } + zv::Val getExpr() const { return zv::Val::copyOf(zv::ObjRef(self).propAt(PT_ETH_PROP_EXPR)); } zv::Val getType() const { return zv::Val::copyOf(zv::ObjRef(self).propAt(PT_ETH_PROP_TYPE)); } zv::Val getCertainty() const { return zv::Val::copyOf(zv::ObjRef(self).propAt(PT_ETH_PROP_CERTAINTY)); } @@ -89,6 +195,7 @@ void pt_register_expression_type_holder() cls.privateNullProperty("expr"); cls.privateNullProperty("type"); cls.privateNullProperty("certainty"); + cls.privateNullProperty("containedNodeKeys"); cls.method("__construct", reg::Public, 3, { reg::any("expr"), reg::any("type"), reg::obj("certainty", TRINARY_CLASS) }, [](INTERNAL_FUNCTION_PARAMETERS) { zval *expr, *type, *certainty; @@ -154,6 +261,19 @@ void pt_register_expression_type_holder() result.intoReturnValue(return_value); }); + cls.method("getContainedNodeKeys", reg::Public, 1, { reg::callableArg("keyBuilder") }, [](INTERNAL_FUNCTION_PARAMETERS) { + zend_fcall_info fci; + zend_fcall_info_cache fcc; + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_FUNC(fci, fcc) + ZEND_PARSE_PARAMETERS_END(); + zv::Val result = ExpressionTypeHolder(ZEND_THIS).getContainedNodeKeys(&fci, &fcc); + if (UNEXPECTED(result.isUndef())) { + RETURN_THROWS(); + } + result.intoReturnValue(return_value); + }); + cls.method("getExpr", reg::Public, 0, {}, [](INTERNAL_FUNCTION_PARAMETERS) { ZEND_PARSE_PARAMETERS_NONE(); ExpressionTypeHolder(ZEND_THIS).getExpr().intoReturnValue(return_value); diff --git a/turbo-ext/src/ScopeOps.cpp b/turbo-ext/src/ScopeOps.cpp index 307f228b36d..be73588ae7e 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 = !strContains(exprStringToInvalidate, "__phpstan", sizeof("__phpstan") - 1) + && !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 @@ -789,37 +861,20 @@ class ScopeOps zend_string_equals_literal(exprStringToInvalidate, "$this"), }; - /* Mirrors the twin's $canUseKeyPrefilter: outside shouldInvalidate()'s - * compositional-key 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 much more expensive - * per-expression check can be skipped without being called. */ - const bool canUseKeyPrefilter = !query.isThis - && !strContains(exprStringToInvalidate, "__phpstan", sizeof("__phpstan") - 1) - && !strContains(exprStringToInvalidate, "/*", 2); - bool invalidated = false; zv::Arr resultExpr, resultNative; /* stay UNDEF until the first hit */ for (auto entry : expressionTypes) { zend_string *key = entry.stringKeyOrNull(); zend_ulong idx = entry.indexKey(); - - if (canUseKeyPrefilter && key != NULL - && !strContainsStr(key, exprStringToInvalidate) - && !keyMayHideSubExpressions(key)) { - continue; - } - zv::Ref holder = entry.value().deref(); if (UNEXPECTED(!pt_check_holder(holder.raw()))) { return zv::Val(); } - zend_object *expr = holderExpr(holder); zend_string *entryKey = key != NULL ? key : zend_long_to_str((zend_long) idx); bool failed = false; - bool should = shouldInvalidate(query, entryKey, expr, requireMoreCharacters, &failed); + bool should = shouldInvalidate(query, entryKey, holder.asObject(), requireMoreCharacters, &failed); if (key == NULL) { zend_string_release(entryKey); } @@ -852,27 +907,24 @@ 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 - || strContainsStr(key, exprStringToInvalidate) - || keyMayHideSubExpressions(key)) { + /* first holder's type-holder expr decides whole-group invalidation */ + { zv::Ref firstHolder = (*holdersTable.begin()).value().deref(); if (UNEXPECTED(!firstHolder.instanceOf(pt_ce_cond_expr_holder))) { 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(), firstTypeHolder.asObject(), requireMoreCharacters, &failed); if (UNEXPECTED(failed)) { return zv::Val(); } @@ -882,30 +934,9 @@ class ScopeOps } } - /* Lazily materialized: stays UNDEF while every holder seen so far - * is kept, so the common no-drop case reuses the original array - * instead of rebuilding it holder by holder. */ - zv::Arr filtered; - uint32_t keptCount = 0; + zv::Arr filtered; /* stays UNDEF until the first kept holder */ for (auto holderEntry : holdersTable) { - zend_string *holderKey = holderEntry.stringKeyOrNull(); zv::Ref holder = holderEntry.value().deref(); - - /* The holder's array key (ConditionalExpressionHolder::getKey()) - * embeds every condition's expression key verbatim, so when the - * invalidated key does not occur in it, none of the conditions - * can contain the invalidated expression and the holder can be - * kept without inspecting its conditions. */ - if (canUseKeyPrefilter && holderKey != NULL - && !strContainsStr(holderKey, exprStringToInvalidate) - && !keyMayHideSubExpressions(holderKey)) { - if (!filtered.isUndef()) { - tableAddNewCopy(filtered.table(), holderKey, holderEntry.indexKey(), holder); - } else { - keptCount++; - } - continue; - } if (UNEXPECTED(!holder.instanceOf(pt_ce_cond_expr_holder))) { zend_type_error("phpstan_turbo: expected ConditionalExpressionHolder"); return zv::Val(); @@ -918,11 +949,10 @@ class ScopeOps if (UNEXPECTED(!pt_check_holder(conditionHolder.raw()))) { return zv::Val(); } - zend_object *conditionExpr = holderExpr(conditionHolder); zend_string *conditionKey = conditionEntry.stringKeyOrNull(); zend_string *conditionKeyStr = conditionKey != NULL ? conditionKey : zend_long_to_str((zend_long) conditionEntry.indexKey()); bool failed = false; - bool should = shouldInvalidate(query, conditionKeyStr, conditionExpr, false, &failed); + bool should = shouldInvalidate(query, conditionKeyStr, conditionHolder.asObject(), false, &failed); if (conditionKey == NULL) { zend_string_release(conditionKeyStr); } @@ -936,35 +966,16 @@ class ScopeOps } } } - if (keep) { - if (!filtered.isUndef()) { - tableAddNewCopy(filtered.table(), holderKey, holderEntry.indexKey(), holder); - } else { - keptCount++; - } + if (!keep) { continue; } - if (filtered.isUndef()) { - /* copy the kept prefix (mirrors the twin's array_slice) */ - filtered = zv::Arr::create(keptCount); - uint32_t copied = 0; - for (auto keptEntry : holdersTable) { - if (copied == keptCount) { - break; - } - tableAddNewCopy(filtered.table(), keptEntry.stringKeyOrNull(), keptEntry.indexKey(), keptEntry.value().deref()); - copied++; - } + filtered = zv::Arr::create(0); } + tableAddNewCopy(filtered.table(), holderEntry.stringKeyOrNull(), holderEntry.indexKey(), holder); } if (filtered.isUndef()) { - /* nothing dropped — share the original holders array */ - tableAddNewCopy(resultConditional.table(), key, idx, holders); - continue; - } - if (zend_hash_num_elements(filtered.table()) == 0) { continue; } tableAddNew(resultConditional.table(), key, idx, std::move(filtered)); @@ -987,80 +998,8 @@ 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 = !strContains(exprStringToInvalidate, "__phpstan", sizeof("__phpstan") - 1) - && !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) + static bool shouldInvalidateExpression(zval *scope, zval *exprPrinter, zend_string *exprStringToInvalidate, zval *exprToInvalidate, zend_object *exprTypeHolder, zend_string *exprString, bool requireMoreCharacters, zval *invalidatingClass, bool *failed) { InvalidationQuery query = { scope, @@ -1070,7 +1009,7 @@ class ScopeOps invalidatingClass, zend_string_equals_literal(exprStringToInvalidate, "$this"), }; - return shouldInvalidate(query, exprString, expr, requireMoreCharacters, failed); + return shouldInvalidate(query, exprString, exprTypeHolder, requireMoreCharacters, failed); } /* Mirrors ScopeOps::getIntertwinedRefRootVariableName(). */ @@ -1087,47 +1026,6 @@ class ScopeOps return zv::Val::null(); } - /* - * Mirrors ScopeOps::buildTypeSpecifications(); the usort becomes a qsort - * over a flat working set, kept stable via a sequence number. - */ - static zv::Val buildTypeSpecifications(zv::TableRef sureTypes, zv::TableRef sureNotTypes) - { - uint32_t capacity = sureTypes.size() + sureNotTypes.size(); - TypeSpec *specs = capacity > 0 ? (TypeSpec *) emalloc(capacity * sizeof(TypeSpec)) : NULL; - uint32_t count = 0; - - if (UNEXPECTED(!collectTypeSpecifications(sureTypes, true, specs, &count)) - || UNEXPECTED(!collectTypeSpecifications(sureNotTypes, false, specs, &count))) { - for (uint32_t i = 0; i < count; i++) { - zend_string_release(specs[i].exprString); - } - if (specs != NULL) { - efree(specs); - } - return zv::Val(); - } - - if (count > 1) { - qsort(specs, count, sizeof(TypeSpec), compareTypeSpecifications); - } - - zv::Arr result = zv::Arr::create(count); - for (uint32_t i = 0; i < count; i++) { - zv::Arr item = zv::Arr::create(4); - item.set("sure", zv::Val::boolean(specs[i].sure)); - item.set("exprString", zv::Val::adoptString(specs[i].exprString)); - item.set("expr", zv::Val::copyOf(zv::Ref(&specs[i].expr))); - item.set("type", zv::Val::copyOf(zv::Ref(&specs[i].type))); - result.push(std::move(item)); - } - - if (specs != NULL) { - efree(specs); - } - return zv::Val(std::move(result)); - } - /* * Mirrors ScopeOps::matchConditionalExpressions(): the fixed-point loop * with an exact-match pass and a supertype-match pass per expression. @@ -1270,27 +1168,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; - }; - - /* One row of buildTypeSpecifications()' sortable working set. */ - struct TypeSpec - { - zend_string *exprString; /* owned */ - zval expr; /* borrowed */ - zval type; /* borrowed */ - bool sure; - uint32_t seq; - }; - static zv::Val trinarySingleton(zend_long value) { return zv::Val::copyOf(zv::Ref(pt_trinary_singleton(value))); @@ -1645,6 +1522,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; @@ -1689,41 +1577,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) { @@ -1825,8 +1678,13 @@ class ScopeOps * per-call (the conditional-holder scan passes false). Returns false and * sets *failed on exception. */ - static bool shouldInvalidate(const InvalidationQuery &query, zend_string *exprString, zend_object *expr, bool requireMoreCharacters, bool *failed) + static bool shouldInvalidate(const InvalidationQuery &query, zend_string *exprString, zend_object *holderObj, bool requireMoreCharacters, bool *failed) { + zend_object *expr; + { + zv::Ref exprRef = zv::ObjRef(holderObj).propAt(PT_ETH_PROP_EXPR).deref(); + expr = exprRef.asObject(); + } zend_class_entry *variableCe = pt_class(PT_CLASS_VARIABLE); zend_class_entry *intertwinedCe = pt_class(PT_CLASS_INTERTWINED_VAR); @@ -1908,8 +1766,10 @@ class ScopeOps return false; } - /* AST walk */ - { + if (query.isThis) { + /* '$this' also matches self/static/parent and the current class + * name - name resolution depends on the asking scope, so the + * holder-cached key index below cannot answer it */ pt_find_ctx ctx; memset(&ctx, 0, sizeof(ctx)); ctx.target_ce = Z_OBJCE_P(query.expressionToInvalidate); @@ -1932,6 +1792,22 @@ class ScopeOps if (found == NULL) { return false; } + } else { + /* one isset() on the holder's contained-node-keys index instead + * of the subtree scan */ + HashTable *contained = pt_holder_contained_node_keys(holderObj, query.exprPrinter); + if (UNEXPECTED(contained == NULL)) { + *failed = true; + return false; + } + zval *inner = zend_symtable_find(contained, query.exprStringToInvalidate); + if (inner == NULL) { + return false; + } + ZVAL_DEREF(inner); + if (Z_TYPE_P(inner) != IS_ARRAY || zend_hash_find(Z_ARRVAL_P(inner), Z_OBJCE_P(query.expressionToInvalidate)->name) == NULL) { + return false; + } } /* Post-checks calling back into the scope (rare paths) */ @@ -1973,78 +1849,39 @@ class ScopeOps return true; } - /* Collects one sureTypes/sureNotTypes table into the sortable working set. */ - static bool collectTypeSpecifications(zv::TableRef input, bool sure, TypeSpec *specs, uint32_t *count) + /* getIntertwinedRefRootVariableName()'s walk; returns a borrowed string */ + static zend_string *intertwinedRootVariableName(zend_object *expr) { - zend_class_entry *scalarCe = pt_class(PT_CLASS_SCALAR); - zend_class_entry *arrayExprCe = pt_class(PT_CLASS_ARRAY_EXPR); - zend_class_entry *unaryMinusCe = pt_class(PT_CLASS_UNARY_MINUS); + zend_class_entry *variableCe = pt_class(PT_CLASS_VARIABLE); + zend_class_entry *arrayDimFetchCe = pt_class(PT_CLASS_ARRAY_DIM_FETCH); - if (UNEXPECTED(scalarCe == NULL || arrayExprCe == NULL || unaryMinusCe == NULL)) { - return false; + if (UNEXPECTED(variableCe == NULL || arrayDimFetchCe == NULL)) { + return NULL; } - for (auto entry : input) { - zv::Ref pair = entry.value().deref(); - if (UNEXPECTED(!pair.isArray())) { - zend_throw_error(NULL, "phpstan_turbo: sure type entry is not an array"); - return false; - } - zval *exprSlot = zend_hash_index_find(pair.asArrayTable(), 0); - zval *typeSlot = zend_hash_index_find(pair.asArrayTable(), 1); - if (UNEXPECTED(exprSlot == NULL || typeSlot == NULL)) { - zend_throw_error(NULL, "phpstan_turbo: malformed sure type entry"); - return false; - } - zv::Ref expr = zv::Ref(exprSlot).deref(); - zv::Ref type = zv::Ref(typeSlot).deref(); - if (UNEXPECTED(!expr.isObject() || !type.isObject())) { - zend_throw_error(NULL, "phpstan_turbo: malformed sure type entry"); - return false; - } - - zend_class_entry *exprCe = expr.asObject()->ce; - if (instanceof_function(exprCe, scalarCe) || instanceof_function(exprCe, arrayExprCe)) { - continue; + 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(exprCe, unaryMinusCe)) { - int32_t subOffset = pt_instance_prop_offset(exprCe, "expr", sizeof("expr") - 1); - if (subOffset >= 0) { - zv::Ref sub = zv::ObjRef(expr.asObject()).propAtOffset((uint32_t) subOffset).deref(); - if (sub.instanceOf(scalarCe)) { - continue; - } + 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; } - - TypeSpec *spec = &specs[*count]; - zend_string *key = entry.stringKeyOrNull(); - spec->exprString = key != NULL ? zend_string_copy(key) : zend_long_to_str((zend_long) entry.indexKey()); - ZVAL_COPY_VALUE(&spec->expr, expr.raw()); - ZVAL_COPY_VALUE(&spec->type, type.raw()); - spec->sure = sure; - spec->seq = *count; - (*count)++; - } - - return true; - } - - /* buildTypeSpecifications()'s usort comparator (stable via seq) */ - static int compareTypeSpecifications(const void *a, const void *b) - { - const TypeSpec *specA = (const TypeSpec *) a; - const TypeSpec *specB = (const TypeSpec *) b; - size_t lengthA = ZSTR_LEN(specA->exprString); - size_t lengthB = ZSTR_LEN(specB->exprString); - - if (lengthA != lengthB) { - return lengthA < lengthB ? -1 : 1; - } - if (specA->sure != specB->sure) { - return specB->sure - specA->sure; /* sure=true first */ + return NULL; } - return specA->seq < specB->seq ? -1 : (specA->seq > specB->seq ? 1 : 0); } /* @@ -2173,8 +2010,8 @@ void pt_register_scope_ops() result.intoReturnValue(return_value); }); - cls.method("shouldInvalidateExpression", reg::PublicStatic, 6, { reg::objectArg("scope"), reg::objectArg("exprPrinter"), reg::stringArg("exprStringToInvalidate"), reg::objectArg("exprToInvalidate"), reg::objectArg("expr"), reg::stringArg("exprString"), reg::boolArg("requireMoreCharacters"), reg::objectArg("invalidatingClass", true) }, [](INTERNAL_FUNCTION_PARAMETERS) { - zval *scope, *expr_printer, *expr_to_invalidate, *expr, *invalidating_class = NULL; + cls.method("shouldInvalidateExpression", reg::PublicStatic, 6, { reg::objectArg("scope"), reg::objectArg("exprPrinter"), reg::stringArg("exprStringToInvalidate"), reg::objectArg("exprToInvalidate"), reg::objectArg("exprTypeHolder"), reg::stringArg("exprString"), reg::boolArg("requireMoreCharacters"), reg::objectArg("invalidatingClass", true) }, [](INTERNAL_FUNCTION_PARAMETERS) { + zval *scope, *expr_printer, *expr_to_invalidate, *expr_type_holder, *invalidating_class = NULL; zend_string *invalidate_str, *expr_string; bool require_more_characters = false; ZEND_PARSE_PARAMETERS_START(6, 8) @@ -2182,15 +2019,18 @@ void pt_register_scope_ops() Z_PARAM_OBJECT(expr_printer) Z_PARAM_STR(invalidate_str) Z_PARAM_OBJECT(expr_to_invalidate) - Z_PARAM_OBJECT(expr) + Z_PARAM_OBJECT(expr_type_holder) Z_PARAM_STR(expr_string) Z_PARAM_OPTIONAL Z_PARAM_BOOL(require_more_characters) Z_PARAM_OBJECT_OR_NULL(invalidating_class) ZEND_PARSE_PARAMETERS_END(); pt_init_strs(); + if (UNEXPECTED(!pt_check_holder(expr_type_holder))) { + RETURN_THROWS(); + } bool failed = false; - bool result = ScopeOps::shouldInvalidateExpression(scope, expr_printer, invalidate_str, expr_to_invalidate, Z_OBJ_P(expr), expr_string, require_more_characters, invalidating_class, &failed); + bool result = ScopeOps::shouldInvalidateExpression(scope, expr_printer, invalidate_str, expr_to_invalidate, Z_OBJ_P(expr_type_holder), expr_string, require_more_characters, invalidating_class, &failed); if (UNEXPECTED(failed)) { RETURN_THROWS(); } @@ -2209,19 +2049,6 @@ void pt_register_scope_ops() result.intoReturnValue(return_value); }); - cls.method("buildTypeSpecifications", reg::PublicStatic, 2, { reg::arrayArg("sureTypes"), reg::arrayArg("sureNotTypes") }, [](INTERNAL_FUNCTION_PARAMETERS) { - HashTable *sure_types, *sure_not_types; - ZEND_PARSE_PARAMETERS_START(2, 2) - Z_PARAM_ARRAY_HT(sure_types) - Z_PARAM_ARRAY_HT(sure_not_types) - ZEND_PARSE_PARAMETERS_END(); - zv::Val result = ScopeOps::buildTypeSpecifications(zv::TableRef(sure_types), zv::TableRef(sure_not_types)); - if (UNEXPECTED(result.isUndef())) { - RETURN_THROWS(); - } - result.intoReturnValue(return_value); - }); - cls.method("matchConditionalExpressions", reg::PublicStatic, 2, { reg::arrayArg("conditionalExpressions"), reg::arrayArg("specifiedExpressions") }, [](INTERNAL_FUNCTION_PARAMETERS) { HashTable *conditional, *specified_input; ZEND_PARSE_PARAMETERS_START(2, 2) diff --git a/turbo-ext/src/support.cpp b/turbo-ext/src/support.cpp index 0fad5118070..027d00baee1 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; } @@ -713,6 +699,93 @@ zend_object *pt_find_first_recursive(zend_object *node, pt_node_matcher matcher, return NULL; } +HashTable *pt_holder_contained_node_keys(zend_object *holder, zval *expr_printer) +{ + zval *slot = OBJ_PROP_NUM(holder, PT_ETH_PROP_CONTAINED_NODE_KEYS); + if (Z_TYPE_P(slot) == IS_ARRAY) { + return Z_ARRVAL_P(slot); + } + + zend_class_entry *expr_ce = pt_class(PT_CLASS_EXPR); + zend_class_entry *node_ce = pt_class(PT_CLASS_NODE); + if (UNEXPECTED(expr_ce == NULL || node_ce == NULL)) { + return NULL; + } + + zval keys; + array_init(&keys); + + /* the twin's explicit stack (LIFO pop order) so the cached array is + * key-order identical to ExpressionTypeHolder::getContainedNodeKeys() */ + uint32_t cap = 32, top = 0; + zend_object **stack = (zend_object **) emalloc(sizeof(*stack) * cap); + { + zval *expr_slot = OBJ_PROP_NUM(holder, PT_ETH_PROP_EXPR); + ZVAL_DEREF(expr_slot); + stack[top++] = Z_OBJ_P(expr_slot); + } + + while (top > 0) { + zend_object *node = stack[--top]; + + if (instanceof_function(node->ce, expr_ce)) { + zend_string *key = pt_node_key(node, expr_printer); + if (UNEXPECTED(key == NULL)) { + efree(stack); + zval_ptr_dtor(&keys); + return NULL; + } + zval *inner = zend_symtable_find(Z_ARRVAL(keys), key); + if (inner == NULL) { + zval inner_zv; + array_init(&inner_zv); + inner = zend_symtable_update(Z_ARRVAL(keys), key, &inner_zv); + } + zval true_zv; + ZVAL_TRUE(&true_zv); + zend_hash_update(Z_ARRVAL_P(inner), node->ce->name, &true_zv); + zend_string_release(key); + } + + pt_node_class_info *info = pt_node_class_info_for_object(node); + if (info == NULL || !PT_HAS_SUBNODES(info)) { + continue; + } + for (uint32_t i = 0; i < info->subnode_count; i++) { + zval *val = OBJ_PROP(node, info->subnode_offsets[i]); + ZVAL_DEREF(val); + if (Z_TYPE_P(val) == IS_OBJECT) { + if (instanceof_function(Z_OBJCE_P(val), node_ce)) { + if (UNEXPECTED(top == cap)) { + cap *= 2; + stack = (zend_object **) erealloc(stack, sizeof(*stack) * cap); + } + stack[top++] = Z_OBJ_P(val); + } + } else if (Z_TYPE_P(val) == IS_ARRAY) { + zval *el; + ZEND_HASH_FOREACH_VAL(Z_ARRVAL_P(val), el) { + zval *el_deref = el; + ZVAL_DEREF(el_deref); + if (Z_TYPE_P(el_deref) == IS_OBJECT && instanceof_function(Z_OBJCE_P(el_deref), node_ce)) { + if (UNEXPECTED(top == cap)) { + cap *= 2; + stack = (zend_object **) erealloc(stack, sizeof(*stack) * cap); + } + stack[top++] = Z_OBJ_P(el_deref); + } + } ZEND_HASH_FOREACH_END(); + } + } + } + efree(stack); + + /* cache in the holder's slot (transfers ownership) and hand out the table */ + zval_ptr_dtor(slot); + ZVAL_COPY_VALUE(slot, &keys); + return Z_ARRVAL_P(slot); +} + static const struct { const char *name; size_t len; } pt_superglobals[] = { {"GLOBALS", 7}, {"_SERVER", 7}, diff --git a/turbo-ext/src/support.h b/turbo-ext/src/support.h index 77c08a77081..73e73028edf 100644 --- a/turbo-ext/src/support.h +++ b/turbo-ext/src/support.h @@ -173,6 +173,7 @@ void pt_arena_mshutdown(); #define PT_ETH_PROP_EXPR 0 #define PT_ETH_PROP_TYPE 1 #define PT_ETH_PROP_CERTAINTY 2 +#define PT_ETH_PROP_CONTAINED_NODE_KEYS 3 #define PT_CEH_PROP_CONDS 0 #define PT_CEH_PROP_TYPEHOLDER 1 @@ -275,7 +276,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); @@ -301,6 +301,11 @@ typedef bool (*pt_node_matcher)(zend_object *node, void *ctx); zend_object *pt_find_first_recursive(zend_object *node, pt_node_matcher matcher, void *ctx); +/* The holder's contained-node-keys index built with the native node-key + * builder, cached in the holder's containedNodeKeys slot. Borrowed table; + * NULL on failure (exception thrown). */ +HashTable *pt_holder_contained_node_keys(zend_object *holder, zval *expr_printer); + bool pt_is_superglobal_name(zend_string *name); /* CONTAINS_SUPER_GLOBAL_ATTRIBUTE_NAME-cached superglobal scan */ bool pt_expr_contains_superglobal(zend_object *expr); diff --git a/turbo-ext/tests/smoke.php b/turbo-ext/tests/smoke.php index e668d303440..f2eff9221d6 100644 --- a/turbo-ext/tests/smoke.php +++ b/turbo-ext/tests/smoke.php @@ -226,6 +226,44 @@ 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'); +// getContainedNodeKeys: identical index (including key insertion order) over the same tree +$tree = new \PhpParser\Node\Expr\BinaryOp\Plus( + new \PhpParser\Node\Expr\Variable('a'), + new \PhpParser\Node\Expr\ArrayDimFetch( + new \PhpParser\Node\Expr\Variable('b'), + new \PhpParser\Node\Scalar\String_('k'), + ), +); +$keyBuilder = static fn (\PhpParser\Node\Expr $e): string => $e instanceof \PhpParser\Node\Expr\Variable && is_string($e->name) + ? '$' . $e->name + : get_class($e) . '#' . spl_object_id($e); +$pContained = $pH($tree, $int, $pYes)->getContainedNodeKeys($keyBuilder); +$nContainedHolder = $nH($tree, $int, $nYes); +$nContained = $nContainedHolder->getContainedNodeKeys($keyBuilder); +check($pContained === $nContained, 'ETH getContainedNodeKeys parity: ' . json_encode($pContained) . ' vs ' . json_encode($nContained)); +$counter = 0; +$countingBuilder = static function (\PhpParser\Node\Expr $e) use (&$counter, $keyBuilder): string { + $counter++; + return $keyBuilder($e); +}; +$nContainedHolder->getContainedNodeKeys($countingBuilder); +check($counter === 0, 'ETH getContainedNodeKeys memoizes (keyBuilder not re-invoked)'); + +// ---- 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 +336,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 +346,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 +390,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(); @@ -565,36 +602,6 @@ public function enterNode(\PhpParser\Node $node) } check($mergeResults['php'] === $mergeResults['native'], 'ScopeOps mergeVariableHolders: merged keys, certainties and types'); -// buildTypeSpecifications — pure, so both sides can share the inputs; the -// scalar/array/unary-minus entries must be dropped and the result ordered by -// expression-string length with sure-before-not tie-breaking -$specInt = new \PHPStan\Type\IntegerType(); -$specString = new \PHPStan\Type\StringType(); -$specSure = [ - '$bb' => [new \PhpParser\Node\Expr\Variable('bb'), $specInt], - '$a' => [new \PhpParser\Node\Expr\Variable('a'), $specInt], - "'lit'" => [new \PhpParser\Node\Scalar\String_('lit'), $specString], - '-5' => [new \PhpParser\Node\Expr\UnaryMinus(new \PhpParser\Node\Scalar\Int_(5)), $specInt], - '[]' => [new \PhpParser\Node\Expr\Array_([]), $specInt], -]; -$specSureNot = [ - '$a' => [new \PhpParser\Node\Expr\Variable('a'), $specString], - '$o->p' => [new \PhpParser\Node\Expr\PropertyFetch(new \PhpParser\Node\Expr\Variable('o'), 'p'), $specString], -]; -$specResults = []; -foreach ($scopeOpsClasses as $side => $scopeOpsClass) { - $specResults[$side] = array_map( - static fn (array $specification): array => [ - $specification['sure'], - $specification['exprString'], - spl_object_id($specification['expr']), - $specification['type']->describe(\PHPStan\Type\VerbosityLevel::precise()), - ], - $scopeOpsClass::buildTypeSpecifications($specSure, $specSureNot), - ); -} -check($specResults['php'] === $specResults['native'], 'ScopeOps buildTypeSpecifications: filtering, ordering and tie-breaking'); - // matchConditionalExpressions — a holder whose conditions are all among the // specified expressions must resolve, transitively (fixed point); '$c' // resolves only after '$b' did, '$unmatched' never does