diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 34173367726..a4d27e26db2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -36,12 +36,6 @@ 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 @@ -69,7 +63,7 @@ parameters: - rawMessage: Casting to string something that's already string. identifier: cast.useless - count: 3 + count: 5 path: src/Analyser/MutatingScope.php - 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/DirectInternalScopeFactory.php b/src/Analyser/DirectInternalScopeFactory.php index 533d37c5f92..02afcab90e5 100644 --- a/src/Analyser/DirectInternalScopeFactory.php +++ b/src/Analyser/DirectInternalScopeFactory.php @@ -19,6 +19,8 @@ final class DirectInternalScopeFactory implements InternalScopeFactory { + private ExpressionResultStorageStack $expressionResultStorageStack; + /** * @param int|array{min: int, max: int}|null $configPhpVersion * @param callable(Node $node, Scope $scope): void|null $nodeCallback @@ -38,8 +40,10 @@ public function __construct( private $nodeCallback, private ConstantResolver $constantResolver, private bool $fiber = false, + ?ExpressionResultStorageStack $expressionResultStorageStack = null, ) { + $this->expressionResultStorageStack = $expressionResultStorageStack ?? new ExpressionResultStorageStack(); } public function create( @@ -77,6 +81,7 @@ public function create( $this->propertyReflectionFinder, $this->parser, $this->constantResolver, + $this->expressionResultStorageStack, $context, $this->phpVersion, $this->attributeReflectionFactory, @@ -102,25 +107,15 @@ public function create( public function toFiberFactory(): InternalScopeFactory { - return new self( - $this->container, - $this->reflectionProvider, - $this->initializerExprTypeResolver, - $this->expressionTypeResolverExtensionRegistryProvider, - $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, @@ -136,7 +131,8 @@ public function toMutatingFactory(): InternalScopeFactory $this->configPhpVersion, $this->nodeCallback, $this->constantResolver, - false, + $fiber, + $this->expressionResultStorageStack, ); } diff --git a/src/Analyser/ExprHandler.php b/src/Analyser/ExprHandler.php index 98b8728ca40..7e93df1a6cf 100644 --- a/src/Analyser/ExprHandler.php +++ b/src/Analyser/ExprHandler.php @@ -5,7 +5,6 @@ use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; -use PHPStan\Type\Type; /** * @template T of Expr @@ -32,19 +31,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 1c389f9fb9a..5e39abd7814 100644 --- a/src/Analyser/ExprHandler/ArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/ArrayDimFetchHandler.php @@ -11,21 +11,22 @@ 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\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; /** @@ -35,59 +36,37 @@ final class ArrayDimFetchHandler implements ExprHandler { - public function supports(Expr $expr): bool + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { - return $expr instanceof ArrayDimFetch; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function supports(Expr $expr): bool { - 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), - ); + return $expr instanceof ArrayDimFetch; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; if ($expr->dim === null) { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $varResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $varResult->hasYield(), isAlwaysTerminating: $varResult->isAlwaysTerminating(), throwPoints: $varResult->getThrowPoints(), impurePoints: $varResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + containsNullsafe: $varResult->containsNullsafe(), + // `$arr[]` only appears as an assignment target; reading it is a NeverType + typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } @@ -97,7 +76,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($dimResult->getImpurePoints(), $varResult->getImpurePoints()); $scope = $varResult->getScope(); - $varType = $scope->getType($expr->var); + $varType = $varResult->getTypeForScope($scope); + $offsetGetResult = null; if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( $stmt, @@ -107,22 +87,45 @@ 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 new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $dimResult->hasYield() || $varResult->hasYield(), isAlwaysTerminating: $dimResult->isAlwaysTerminating() || $varResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } + 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 (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/ArrayHandler.php b/src/Analyser/ExprHandler/ArrayHandler.php index f64a168e848..094bea8a0c4 100644 --- a/src/Analyser/ExprHandler/ArrayHandler.php +++ b/src/Analyser/ExprHandler/ArrayHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Arg; use PhpParser\Node\Expr; use PhpParser\Node\Expr\Array_; @@ -10,23 +11,23 @@ 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\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 @@ -37,6 +38,7 @@ final class ArrayHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -46,33 +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 = []; @@ -82,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()); @@ -90,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()); @@ -98,18 +80,53 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $nodeScopeResolver->callNodeCallback($nodeCallback, new LiteralArrayNode($expr, $itemNodes), $scope, $storage); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, 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: static fn () => new SpecifiedTypes(), + ); } } diff --git a/src/Analyser/ExprHandler/ArrowFunctionHandler.php b/src/Analyser/ExprHandler/ArrowFunctionHandler.php index 0cdfddf675d..aac27203ae9 100644 --- a/src/Analyser/ExprHandler/ArrowFunctionHandler.php +++ b/src/Analyser/ExprHandler/ArrowFunctionHandler.php @@ -7,17 +7,15 @@ 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\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 @@ -28,6 +26,8 @@ final class ArrowFunctionHandler implements ExprHandler public function __construct( private ClosureTypeResolver $closureTypeResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -39,25 +39,50 @@ 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(); - return new ExpressionResult( + // A plain typeCallback recursing through getClosureType() would re-walk + // the body each getType() ask before the cache populates and hang; + // ExpressionResult excludes closures from its tracked-type early return. + // Compute the ClosureType once here and store it as an eager value. + // + // Both flavours are built from the arrow function body the single walk in + // processArrowFunctionNode() already covered, without a second walk: the + // native flavour reads the body expression's stored native types off the + // same arrowScope (an arrow's native return type is its body's native type). + $arrowScope = $arrowFunctionResult->getArrowFunctionScope(); + $type = $this->closureTypeResolver->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ); + $nativeType = $this->closureTypeResolver->buildClosureTypeForArrowFunction( + $scope, + $expr, + $arrowScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + native: true, + ); + + return $this->expressionResultFactory->create( $result->getScope(), + beforeScope: $scope, + expr: $expr, hasYield: $result->hasYield(), isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $c) => $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 540119391e7..1eacbc02119 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -25,9 +25,11 @@ use PHPStan\Analyser\ConditionalExpressionHolder; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\ExpressionTypeHolder; use PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\ExprHandler\Helper\DefaultNarrowingHelper; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -35,13 +37,10 @@ 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\ExistingArrayDimFetch; -use PHPStan\Node\Expr\GetOffsetValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; -use PHPStan\Node\Expr\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; use PHPStan\Node\Expr\SetOffsetValueTypeExpr; use PHPStan\Node\Expr\TypeExpr; @@ -51,6 +50,7 @@ use PHPStan\Node\VariableAssignNode; use PHPStan\Node\VirtualNode; use PHPStan\Php\PhpVersion; +use PHPStan\Rules\Properties\PropertyReflectionFinder; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; @@ -72,6 +72,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; +use PHPStan\Type\UnionType; use TypeError; use function array_key_last; use function array_merge; @@ -91,10 +92,12 @@ final class AssignHandler implements ExprHandler { public function __construct( - private TypeSpecifier $typeSpecifier, private PhpVersion $phpVersion, private ExprPrinter $exprPrinter, private MatchHandler $matchHandler, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private PropertyReflectionFinder $propertyReflectionFinder, ) { } @@ -104,191 +107,10 @@ public function supports(Expr $expr): bool return $expr instanceof Assign || $expr instanceof AssignRef; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->expr); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$expr instanceof Assign) { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - if ($context->null()) { - $specifiedTypes = $typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->expr, $context)->setRootExpr($expr); - $specifiedTypes = $specifiedTypes->removeExpr($this->exprPrinter->printExpr($expr->var)); - } else { - $specifiedTypes = $typeSpecifier->specifyTypesInCondition($scope->exitFirstLevelStatements(), $expr->var, $context)->setRootExpr($expr); - } - - // infer $arr[$key] after $key = array_key_first/last($arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && in_array($expr->expr->name->toLowerString(), ['array_key_first', 'array_key_last'], true) - && count($expr->expr->getArgs()) >= 1 - ) { - $arrayArg = $expr->expr->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); - $isNonEmpty = true; - } else { - $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); - } - - if ($isNonEmpty) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $nonNullKeyType = TypeCombinator::removeNull($keyType); - if (!$nonNullKeyType instanceof NeverType) { - $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()), - ); - } - } - } - } - - // infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && count($expr->expr->getArgs()) >= 2 - ) { - $funcName = $expr->expr->name->toLowerString(); - $arrayArg = null; - $sentinelType = null; - $isStrictArraySearch = false; - - if ($funcName === 'array_search') { - $arrayArg = $expr->expr->getArgs()[1]->value; - $sentinelType = new ConstantBooleanType(false); - $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $scope->getType($expr->expr->getArgs()[2]->value)->isTrue()->yes(); - } elseif ($funcName === 'array_find_key') { - $arrayArg = $expr->expr->getArgs()[0]->value; - $sentinelType = new NullType(); - } - - if ($arrayArg !== null) { - $arrayType = $scope->getType($arrayArg); - - if ($arrayType->isArray()->yes()) { - if ($context->true()) { - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($arrayArg, new NonEmptyArrayType(), TypeSpecifierContext::createTrue(), $scope), - ); - - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - if ($isStrictArraySearch) { - $needleType = $scope->getType($expr->expr->getArgs()[0]->value); - $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); - } else { - $dimFetchType = $arrayType->getIterableValueType(); - } - - $specifiedTypes = $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $dimFetchType, TypeSpecifierContext::createTrue(), $scope), - ); - } elseif ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { - $keyType = $scope->getType($expr->expr); - $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); - if (!$narrowedKeyType instanceof NeverType) { - if ($isStrictArraySearch) { - $needleType = $scope->getType($expr->expr->getArgs()[0]->value); - $dimFetchType = TypeCombinator::intersect($needleType, $arrayType->getIterableValueType()); - } else { - $dimFetchType = $arrayType->getIterableValueType(); - } - $specifiedTypes = $specifiedTypes->unionWith( - $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $narrowedKeyType, $dimFetchType), - ); - } - } - } - } - } - - if ($context->null()) { - // infer $arr[$key] after $key = array_rand($arr) - if ( - $expr->expr instanceof FuncCall - && $expr->expr->name instanceof Name - && !$expr->expr->isFirstClassCallable() - && in_array($expr->expr->name->toLowerString(), ['array_rand'], true) - && count($expr->expr->getArgs()) >= 1 - ) { - $numArg = null; - $args = $expr->expr->getArgs(); - $arrayArg = $args[0]->value; - if (count($args) > 1) { - $numArg = $args[1]->value; - } - $one = new ConstantIntegerType(1); - $arrayType = $scope->getType($arrayArg); - - if ( - $arrayType->isArray()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() - && ($numArg === null || $one->isSuperTypeOf($scope->getType($numArg))->yes()) - ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - return $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } - } - - // infer $list[$count] after $count = count($list) - 1 - if ( - $expr->expr instanceof Expr\BinaryOp\Minus - && $expr->expr->left instanceof FuncCall - && $expr->expr->left->name instanceof Name - && !$expr->expr->left->isFirstClassCallable() - && $expr->expr->right instanceof Node\Scalar\Int_ - && $expr->expr->right->value === 1 - && in_array($expr->expr->left->name->toLowerString(), ['count', 'sizeof'], true) - && count($expr->expr->left->getArgs()) >= 1 - ) { - $arrayArg = $expr->expr->left->getArgs()[0]->value; - $arrayType = $scope->getType($arrayArg); - if ( - $arrayType->isList()->yes() - && $arrayType->isIterableAtLeastOnce()->yes() - ) { - $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); - - return $specifiedTypes->unionWith( - $typeSpecifier->create($dimFetch, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope), - ); - } - } - - return $specifiedTypes; - } - - return $specifiedTypes; - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; + $assignedExprResult = null; $result = $this->processAssignVar( $nodeScopeResolver, $scope, @@ -298,7 +120,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->expr, $nodeCallback, $context, - static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver, &$assignedExprResult): ExpressionResult { + $beforeScope = $scope; $impurePoints = []; if ($expr instanceof AssignRef) { $referencedExpr = $expr->expr; @@ -326,8 +149,8 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex ); } - $nodeScopeResolver->storeBeforeScope($storage, $expr, $scope); $result = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); + $assignedExprResult = $result; $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); @@ -338,7 +161,17 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $scope = $scope->exitExpressionAssign($expr->expr); } - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr->expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $result->getNativeType() : $result->getType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), + ); }, true, ); @@ -353,8 +186,8 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex ) { $varName = $expr->var->name; $refName = $expr->expr->name; - $type = $scope->getType($expr->var); - $nativeType = $scope->getNativeType($expr->var); + $type = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); + $nativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); // When $varName is assigned, update $refName $scope = $scope->assignExpression( @@ -380,17 +213,228 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $result->hasYield(), isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + typeCallback: static function (bool $nativeTypesPromoted) use ($assignedExprResult, $nodeScopeResolver, $expr, $beforeScope): Type { + if ($assignedExprResult !== null) { + return $nativeTypesPromoted ? $assignedExprResult->getNativeType() : $assignedExprResult->getType(); + } + + return $nativeTypesPromoted + ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->expr, $beforeScope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $beforeScope); + }, + specifyTypesCallback: $expr instanceof Assign ? $this->createSpecifyTypesCallback($nodeScopeResolver, $expr, $assignedExprResult) : fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + createTypesCallback: $expr instanceof Assign ? $this->createCreateTypesCallback($expr, $assignedExprResult) : 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(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes + */ + private function createCreateTypesCallback(Assign $expr, ?ExpressionResult $assignedExprResult): Closure + { + return function (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $assignedExprResult): SpecifiedTypes { + $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(MutatingScope, TypeSpecifierContext): SpecifiedTypes + */ + private function createSpecifyTypesCallback(NodeScopeResolver $nodeScopeResolver, Assign $expr, ?ExpressionResult $assignedExprResult): Closure + { + return function (MutatingScope $s, TypeSpecifierContext $context) use ($nodeScopeResolver, $expr, $assignedExprResult): SpecifiedTypes { + 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); + } + + // 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->readStoredOrPriceOnDemand($arrayArg, $s); + + if ($arrayType->isArray()->yes()) { + if ($context->true()) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $arrayArg, null, new NonEmptyArrayType(), TypeSpecifierContext::createTrue()), + ); + $isNonEmpty = true; + } else { + $isNonEmpty = $arrayType->isIterableAtLeastOnce()->yes(); + } + + if ($isNonEmpty) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + $specifiedTypes = $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } elseif ($expr->var instanceof Variable && is_string($expr->var->name)) { + $keyType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr, $s); + $nonNullKeyType = TypeCombinator::removeNull($keyType); + if (!$nonNullKeyType instanceof NeverType) { + $specifiedTypes = $specifiedTypes->unionWith( + $this->createArrayDimFetchConditionalExpressionHolder($expr->var, $arrayArg, $nonNullKeyType, $arrayType->getIterableValueType()), + ); + } + } + } + } + + // infer $arr[$key] after $key = array_search($needle, $arr) or $key = array_find_key($arr, $callback) + if ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() + && count($expr->expr->getArgs()) >= 2 + ) { + $funcName = $expr->expr->name->toLowerString(); + $arrayArg = null; + $sentinelType = null; + $isStrictArraySearch = false; + + if ($funcName === 'array_search') { + $arrayArg = $expr->expr->getArgs()[1]->value; + $sentinelType = new ConstantBooleanType(false); + $isStrictArraySearch = count($expr->expr->getArgs()) >= 3 && $nodeScopeResolver->readStoredOrPriceOnDemand($expr->expr->getArgs()[2]->value, $s)->isTrue()->yes(); + } elseif ($funcName === 'array_find_key') { + $arrayArg = $expr->expr->getArgs()[0]->value; + $sentinelType = new NullType(); + } + + if ($arrayArg !== null) { + $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($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->readStoredOrPriceOnDemand($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->readStoredOrPriceOnDemand($expr->expr, $s); + $narrowedKeyType = TypeCombinator::remove($keyType, $sentinelType); + if (!$narrowedKeyType instanceof NeverType) { + if ($isStrictArraySearch) { + $needleType = $nodeScopeResolver->readStoredOrPriceOnDemand($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 ( + $expr->expr instanceof FuncCall + && $expr->expr->name instanceof Name + && !$expr->expr->isFirstClassCallable() + && in_array($expr->expr->name->toLowerString(), ['array_rand'], true) + && count($expr->expr->getArgs()) >= 1 + ) { + $numArg = null; + $args = $expr->expr->getArgs(); + $arrayArg = $args[0]->value; + if (count($args) > 1) { + $numArg = $args[1]->value; + } + $one = new ConstantIntegerType(1); + $arrayType = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); + + if ( + $arrayType->isArray()->yes() + && $arrayType->isIterableAtLeastOnce()->yes() + && ($numArg === null || $one->isSuperTypeOf($nodeScopeResolver->readStoredOrPriceOnDemand($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 = $nodeScopeResolver->readStoredOrPriceOnDemand($arrayArg, $s); + if ( + $arrayType->isList()->yes() + && $arrayType->isIterableAtLeastOnce()->yes() + ) { + $dimFetch = new ArrayDimFetch($arrayArg, $expr->var); + + return $specifiedTypes->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $dimFetch, null, $arrayType->getIterableValueType(), TypeSpecifierContext::createTrue()), + ); + } + } + } + + return $specifiedTypes; + }; + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback * @param Closure(MutatingScope $scope): ExpressionResult $processExprCallback @@ -408,6 +452,7 @@ public function processAssignVar( bool $enterExpressionAssign, ): ExpressionResult { + $beforeScope = $scope; $nodeScopeResolver->callNodeCallback($nodeCallback, $var, $enterExpressionAssign ? $scope->enterExpressionAssign($var) : $scope, $storage); $hasYield = false; $throwPoints = []; @@ -415,7 +460,6 @@ public function processAssignVar( $isAlwaysTerminating = false; $isAssignOp = $assignedExpr instanceof Expr\AssignOp && !$enterExpressionAssign; if ($var instanceof Variable) { - $nodeScopeResolver->storeBeforeScope($storage, $var, $scope); $result = $processExprCallback($scope); $hasYield = $result->hasYield(); $throwPoints = $result->getThrowPoints(); @@ -428,7 +472,7 @@ public function processAssignVar( $impurePoints[] = new ImpurePoint($scopeBeforeAssignEval, $var, 'superglobal', 'assign to superglobal variable', true); } $assignedExpr = $this->unwrapAssign($assignedExpr); - $type = $scopeBeforeAssignEval->getType($assignedExpr); + $type = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scopeBeforeAssignEval); $conditionalExpressions = []; if ($assignedExpr instanceof Ternary) { @@ -437,41 +481,41 @@ public function processAssignVar( $if = $assignedExpr->cond; } $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); + $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->readStoredOrPriceOnDemand($if, $truthyScope); + $falseyType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr->else, $falsyScope); 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); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $condScope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } } if ($assignedExpr instanceof Match_) { $conditionalExpressions = $this->mergeConditionalExpressions( $conditionalExpressions, - $this->processMatchForConditionalExpressionsAfterAssign($scopeBeforeAssignEval, $var->name, $assignedExpr), + $this->processMatchForConditionalExpressionsAfterAssign($nodeScopeResolver, $scopeBeforeAssignEval, $var->name, $assignedExpr), ); } $truthyType = TypeCombinator::removeFalsey($type); if ($truthyType !== $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 = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createTruthy()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $truthySpecifiedTypes, $truthyType, $impurePoints, $assignedExpr); $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 = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $assignedExpr, TypeSpecifierContext::createFalsey()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $falseySpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } foreach ([null, false, 0, 0.0, '', '0', []] as $falseyScalar) { @@ -499,24 +543,24 @@ public function processAssignVar( } $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); + $notIdenticalSpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $notIdenticalConditionExpr, TypeSpecifierContext::createTrue()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $notIdenticalSpecifiedTypes, $withoutFalseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $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); + $identicalSpecifiedTypes = $this->defaultNarrowingHelper->specifyTypesForNode($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue()); + $conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); + $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($nodeScopeResolver, $scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType, $impurePoints, $assignedExpr); } $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, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope), 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); @@ -533,7 +577,7 @@ public function processAssignVar( while ($var instanceof ArrayDimFetch) { $varForSetOffsetValue = $var->var; if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new OriginalPropertyTypeExpr($varForSetOffsetValue); + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($nodeScopeResolver, $varForSetOffsetValue, $scope)); } if ( @@ -561,12 +605,12 @@ public function processAssignVar( if ($enterExpressionAssign) { $scope = $scope->enterExpressionAssign($var); } - $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(); + $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); } @@ -587,19 +631,41 @@ public function processAssignVar( if ($dimExpr === null) { $offsetTypes[] = [null, $dimFetch]; $offsetNativeTypes[] = [null, $dimFetch]; - $nodeScopeResolver->storeBeforeScope($storage, $dimFetch, $scope); + $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $dimFetch, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (): Type => new NeverType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), + )); } else { - $offsetTypes[] = [$scope->getType($dimExpr), $dimFetch]; - $offsetNativeTypes[] = [$scope->getNativeType($dimExpr), $dimFetch]; - if ($enterExpressionAssign) { $scope->enterExpressionAssign($dimExpr); } - $nodeScopeResolver->storeBeforeScope($storage, $dimFetch, $scope); + // 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->getTypeForScope($scope), $dimFetch]; + $offsetNativeTypes[] = [$result->getNativeTypeForScope($scope), $dimFetch]; $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); + + $nodeScopeResolver->storeExpressionResult($storage, $dimFetch, $this->expressionResultFactory->create( + $scope, + beforeScope: $scope, + expr: $dimFetch, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($dimFetch->var, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->var, $scope))->getOffsetValueType($nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($dimExpr, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope)), + specifyTypesCallback: static fn () => new SpecifiedTypes(), + )); $scope = $result->getScope(); if ($enterExpressionAssign) { @@ -608,11 +674,13 @@ public function processAssignVar( } } - $valueToWrite = $scope->getType($assignedExpr); - $nativeValueToWrite = $scope->getNativeType($assignedExpr); + // 3. eval assigned expr first, then read the assigned value on the pre-eval + // scope - so the read consumes the now-stored result of $assignedExpr (and + // of its operands) instead of pricing unprocessed nodes (mirrors the + // Variable branch above). The ??= left side's optional array{} branch is + // preserved by the coalesce typeCallback carrying the isset descriptor, not + // by reading a stale resolvedTypes cache (bug-13623). $scopeBeforeAssignEval = $scope; - - // 3. eval assigned expr $result = $processExprCallback($scope); $hasYield = $hasYield || $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); @@ -620,8 +688,11 @@ public function processAssignVar( $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $scope = $result->getScope(); - $varType = $scope->getType($var); - $varNativeType = $scope->getNativeType($var); + $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scopeBeforeAssignEval); + $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scopeBeforeAssignEval); + + $varType = $varResult->getTypeForScope($scope); + $varNativeType = $varResult->getNativeTypeForScope($scope); // 4. compose types $isImplicitArrayCreation = $this->isImplicitArrayCreation($dimFetchStack, $scope); @@ -632,10 +703,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]) { @@ -654,7 +725,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; } @@ -672,7 +743,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->readStoredOrPriceOnDemand($var->var, $scope), $var->name->toString()); } } $scope = $scope->assignExpression( @@ -687,7 +758,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->readStoredOrPriceOnDemand($var->var, $scope), $var->name->toString()); } } } @@ -702,7 +773,7 @@ public function processAssignVar( $scope = $scope->assignExpression($expr, $type, $nativeType); } - $setVarType = $scope->getType($originalVar->var); + $setVarType = $nodeScopeResolver->readStoredOrPriceOnDemand($originalVar->var, $scope); if ( !$setVarType instanceof ErrorType && !$setVarType->isArray()->yes() @@ -718,7 +789,6 @@ public function processAssignVar( )->getThrowPoints()); } } elseif ($var instanceof PropertyFetch) { - $nodeScopeResolver->storeBeforeScope($storage, $var, $scope); $objectResult = $nodeScopeResolver->processExprNode($stmt, $var->var, $scope, $storage, $nodeCallback, $context); $hasYield = $objectResult->hasYield(); $throwPoints = $objectResult->getThrowPoints(); @@ -750,10 +820,10 @@ public function processAssignVar( $throwPoints[] = InternalThrowPoint::createImplicit($scope, $var); } - $propertyHolderType = $scope->getType($var->var); + $propertyHolderType = $objectResult->getTypeForScope($scope); if ($propertyName !== null && $propertyHolderType->hasInstanceProperty($propertyName)->yes()) { $propertyReflection = $propertyHolderType->getInstanceProperty($propertyName, $scope); - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -770,16 +840,16 @@ public function processAssignVar( } if ($assignedTypeIsCompatible) { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } else { $scope = $scope->assignExpression( $var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), - TypeCombinator::intersect($scope->getNativeType($assignedExpr)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), + TypeCombinator::intersect($nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), ); } } else { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } } $declaringClass = $propertyReflection->getDeclaringClass(); @@ -814,9 +884,9 @@ public function processAssignVar( } } else { // fallback - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($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, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); // simulate dynamic property assign by __set to get throw points if (!$propertyHolderType->hasMethod('__set')->no()) { $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( @@ -831,12 +901,11 @@ public function processAssignVar( } } elseif ($var instanceof Expr\StaticPropertyFetch) { - $nodeScopeResolver->storeBeforeScope($storage, $var, $scope); 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); + $classResult = $nodeScopeResolver->processExprNode($stmt, $var->class, $scope, $storage, $nodeCallback, $context); + $propertyHolderType = $classResult->getTypeForScope($scope); } $propertyName = null; @@ -861,7 +930,7 @@ public function processAssignVar( if ($propertyName !== null) { $propertyReflection = $scope->getStaticPropertyReflection($propertyHolderType, $propertyName); - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); $nodeScopeResolver->callNodeCallback($nodeCallback, new PropertyAssignNode($var, $assignedExpr, $isAssignOp), $scopeBeforeAssignEval, $storage); if ($propertyReflection !== null && $propertyReflection->canChangeTypeAfterAssignment()) { if ($propertyReflection->hasNativeType()) { @@ -878,26 +947,25 @@ public function processAssignVar( } if ($assignedTypeIsCompatible) { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } else { $scope = $scope->assignExpression( $var, TypeCombinator::intersect($assignedExprType->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), - TypeCombinator::intersect($scope->getNativeType($assignedExpr)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), + TypeCombinator::intersect($nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)->toCoercedArgumentType($scope->isDeclareStrictTypes()), $propertyNativeType), ); } } else { - $scope = $scope->assignExpression($var, $assignedExprType, $scope->getNativeType($assignedExpr)); + $scope = $scope->assignExpression($var, $assignedExprType, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } } } else { // fallback - $assignedExprType = $scope->getType($assignedExpr); + $assignedExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($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, $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope)); } } elseif ($var instanceof List_) { - $nodeScopeResolver->storeBeforeScope($storage, $var, $scope); $result = $processExprCallback($scope); $hasYield = $result->hasYield(); $throwPoints = array_merge($throwPoints, $result->getThrowPoints()); @@ -929,16 +997,19 @@ public function processAssignVar( } else { $dimExpr = $arrayItem->key; } + $getOffsetValueTypeExpr = new TypeExpr($nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope)->getOffsetValueType($nodeScopeResolver->readStoredOrPriceOnDemand($dimExpr, $scope))); $result = $this->processAssignVar( $nodeScopeResolver, $scope, $storage, $stmt, $arrayItem->value, - new GetOffsetValueTypeExpr($assignedExpr, $dimExpr), + $getOffsetValueTypeExpr, $nodeCallback, $context, - static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $getOffsetValueTypeExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),), $enterExpressionAssign, ); $scope = $result->getScope(); @@ -953,7 +1024,7 @@ public function processAssignVar( while ($var instanceof ExistingArrayDimFetch) { $varForSetOffsetValue = $var->getVar(); if ($varForSetOffsetValue instanceof PropertyFetch || $varForSetOffsetValue instanceof StaticPropertyFetch) { - $varForSetOffsetValue = new OriginalPropertyTypeExpr($varForSetOffsetValue); + $varForSetOffsetValue = new TypeExpr($this->getOriginalPropertyType($nodeScopeResolver, $varForSetOffsetValue, $scope)); } $assignedPropertyExpr = new SetExistingOffsetValueTypeExpr( $varForSetOffsetValue, @@ -964,18 +1035,24 @@ public function processAssignVar( $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 + $varResult = $nodeScopeResolver->processExprNode($stmt, $var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $offsetTypes = []; $offsetNativeTypes = []; foreach (array_reverse($dimFetchStack) as $dimFetch) { $dimExpr = $dimFetch->getDim(); - $offsetTypes[] = [$scope->getType($dimExpr), $dimFetch]; - $offsetNativeTypes[] = [$scope->getNativeType($dimExpr), $dimFetch]; + $dimResult = $nodeScopeResolver->processExprNode($stmt, $dimExpr, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + $offsetTypes[] = [$dimResult->getTypeForScope($scope), $dimFetch]; + $offsetNativeTypes[] = [$dimResult->getNativeTypeForScope($scope), $dimFetch]; } - $valueToWrite = $scope->getType($assignedExpr); - $nativeValueToWrite = $scope->getNativeType($assignedExpr); - $varType = $scope->getType($var); - $varNativeType = $scope->getNativeType($var); + $valueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemand($assignedExpr, $scope); + $nativeValueToWrite = $nodeScopeResolver->readStoredOrPriceOnDemandNative($assignedExpr, $scope); + $varType = $varResult->getTypeForScope($scope); + $varNativeType = $varResult->getNativeTypeForScope($scope); $offsetValueType = $varType; $offsetNativeValueType = $varNativeType; @@ -1030,7 +1107,9 @@ public function processAssignVar( } // stored where processAssignVar is called - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return $this->expressionResultFactory->create($scope, $beforeScope, $var, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),); } private function createArrayDimFetchConditionalExpressionHolder( @@ -1068,7 +1147,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): array { foreach ($specifiedTypes->getSureTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1083,7 +1162,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $innerExpr, $this->exprPrinter->printExpr($innerExpr), - $scope->getType($innerExpr), + $this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $innerExpr), TrinaryLogic::createMaybe(), ); continue; @@ -1097,7 +1176,7 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco $variableType, $expr, $exprString, - TypeCombinator::intersect($scope->getType($expr), $exprType), + TypeCombinator::intersect($this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $expr), $exprType), TrinaryLogic::createYes(), ); } @@ -1110,7 +1189,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): array { foreach ($specifiedTypes->getSureNotTypes() as $exprString => [$expr, $exprType]) { if (!$this->isExprSafeToProjectThroughVariable($expr, $variableName, $rhsImpurePoints, $assignedExpr)) { @@ -1139,7 +1218,7 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ $variableType, $expr, $exprString, - TypeCombinator::remove($scope->getType($expr), $exprType), + TypeCombinator::remove($this->currentTypeForConditionalHolder($nodeScopeResolver, $scope, $expr), $exprType), TrinaryLogic::createYes(), ); } @@ -1147,6 +1226,27 @@ 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 readStoredOrPriceOnDemand(), + * 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): 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); + } + + return $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); + } + /** * @param array $conditionalExpressions * @return array @@ -1205,12 +1305,13 @@ private function mergeConditionalExpressions(array $conditionalExpressions, arra * @return array */ private function processMatchForConditionalExpressionsAfterAssign( + NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, string $variableName, Match_ $expr, ): array { - $armScopesAndTypes = $this->matchHandler->getArmScopesAndTypes($scope, $expr); + $armScopesAndTypes = $this->matchHandler->getArmScopesAndTypes($nodeScopeResolver, $scope, $expr); if (count($armScopesAndTypes) < 2) { return []; } @@ -1339,12 +1440,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->readStoredOrPriceOnDemand($arrayItem->key, $scope)->toArrayKey(); if ($implicitIndex !== null) { $keyValues = $keyType->getConstantScalarValues(); @@ -1370,7 +1471,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)) { @@ -1379,8 +1480,8 @@ 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)); + $refType = $nodeScopeResolver->readStoredOrPriceOnDemand(new Variable($refVarName), $scope); + $refNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative(new Variable($refVarName), $scope); // When $rootVarName's array key changes, update $refVarName $scope = $scope->assignExpression( @@ -1408,7 +1509,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; @@ -1427,14 +1528,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->readStoredOrPriceOnDemand($dimFetch, $scope); } else { $offsetValueType = $offsetValueType->getOffsetValueType($offsetType); } } elseif ($has->maybe()) { if ($scope->hasExpressionType($dimFetch)->yes()) { $generalizeOnWrite = false; - $offsetValueType = $scope->getType($dimFetch); + $offsetValueType = $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch, $scope); } else { $offsetValueType = TypeCombinator::union($offsetValueType->getOffsetValueType($offsetType), new ConstantArrayType([], [])); } @@ -1512,7 +1613,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()); } @@ -1535,7 +1636,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar } elseif (isset($computedContainerValues[$key])) { $additionalValueType = $computedContainerValues[$key]; } else { - $offsetType = $scope->getType($dimFetch->dim); + $offsetType = $nodeScopeResolver->readStoredOrPriceOnDemand($dimFetch->dim, $scope); $additionalValueType = $valueToWrite->getOffsetValueType($offsetType); } @@ -1545,7 +1646,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 @@ -1571,7 +1672,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->readStoredOrPriceOnDemand($arrayDimFetch->dim, $scope))->yes() && $offsetValueType->isIterableAtLeastOnce()->yes() ) { return true; @@ -1605,4 +1706,20 @@ private function isSameVariable(Expr $a, Expr $b): bool return false; } + /** + * 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(NodeScopeResolver $nodeScopeResolver, PropertyFetch|StaticPropertyFetch $propertyFetch, MutatingScope $scope): Type + { + $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($propertyFetch, $scope); + $originalPropertyType = $propertyReflection !== null ? $propertyReflection->getReadableType() : new ErrorType(); + if ($originalPropertyType instanceof UnionType) { + $currentPropertyType = $nodeScopeResolver->readStoredOrPriceOnDemand($propertyFetch, $scope); + $originalPropertyType = $originalPropertyType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); + } + + return $originalPropertyType; + } + } diff --git a/src/Analyser/ExprHandler/AssignOpHandler.php b/src/Analyser/ExprHandler/AssignOpHandler.php index 6208008ee9c..55559f12fef 100644 --- a/src/Analyser/ExprHandler/AssignOpHandler.php +++ b/src/Analyser/ExprHandler/AssignOpHandler.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser\ExprHandler; use DivisionByZeroError; +use PHPStan\Type\MixedType; use PhpParser\Node\Expr; use PhpParser\Node\Expr\AssignOp; use PhpParser\Node\Expr\BinaryOp; @@ -11,17 +12,19 @@ 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\ImplicitToStringCallHelper; 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; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Node\CoalesceExpressionNode; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Constant\ConstantIntegerType; @@ -43,6 +46,8 @@ public function __construct( private AssignHandler $assignHandler, private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -54,6 +59,118 @@ 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; + + if ( + !$expr instanceof Expr\AssignOp\Coalesce + && ($expr->var instanceof Expr\Variable + || $expr->var instanceof Expr\PropertyFetch + || $expr->var instanceof Expr\StaticPropertyFetch) + ) { + // `$lvalue OP= ...` reads the old value of `$lvalue`; processAssignVar() + // processes a Variable/property target only as an assignment target, never + // the whole lvalue as a read, so it never stores its ExpressionResult. + // Process it here as a read so the typeCallback below consumes the stored + // result instead of pricing the unprocessed lvalue on demand (single-pass + // inside-out). The NoopNodeCallback avoids duplicate reports: + // processAssignVar() already presents the target (and its sub-expressions) + // to the node callback. (An ArrayDimFetch target is stored by + // processAssignVar itself, so it is left out here.) + $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, new NoopNodeCallback(), $context->enterDeep()); + } + + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $nodeScopeResolver, $beforeScope): Type { + // $expr->var and $expr->expr were processed during this handler's + // processExpr (the var as the assignment target, the value expr by the + // inner closure below), so their ExpressionResults are stored - read + // them instead of re-walking via Scope::getType(). + $getType = static fn (Expr $e): Type => $nativeTypesPromoted + ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $beforeScope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $beforeScope); + + if ($expr instanceof Expr\AssignOp\Coalesce) { + // The coalesce is synthetic; price it on demand. The ??= left is stored + // as an assignment target (no isset descriptor), so inject a read result + // of it (with the descriptor) - otherwise the coalesce resolves a + // descriptor-less leaf that reads as definitely-set and drops the `??` + // branch, losing the optional offset natively (bug-13623). + $coalesce = new BinaryOp\Coalesce($expr->var, $expr->expr, $expr->getAttributes()); + $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); + $coalesceStorage = ($beforeScope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage())->duplicate(); + $nodeScopeResolver->storeExpressionResult($coalesceStorage, $expr->var, $varReadResult); + + $coalesceResult = $nodeScopeResolver->processExprOnDemand($coalesce, $beforeScope, $coalesceStorage); + + return $nativeTypesPromoted ? $coalesceResult->getNativeType() : $coalesceResult->getType(); + } + + 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 (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + + // processAssignVar asks getType($expr) for the value to assign; store this + // result first so it resolves from the typeCallback above rather than + // re-processing the node on demand (which would recurse). + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); + $assignResult = $this->assignHandler->processAssignVar( $nodeScopeResolver, $scope, @@ -63,12 +180,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr, $nodeCallback, $context, - static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $storage, $nodeScopeResolver): ExpressionResult { + 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'))), - ); + $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createFalsey())); if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) { $context = $context->enterRightSideAssign( @@ -80,14 +195,17 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); if ($expr instanceof Expr\AssignOp\Coalesce) { - $nodeScopeResolver->storeBeforeScope($storage, $expr, $originalScope); - $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $originalScope->getType($expr->var)->isNull()->yes(); - return new ExpressionResult( + $isAlwaysTerminating = $exprResult->isAlwaysTerminating() && $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $originalScope)->isNull()->yes(); + return $this->expressionResultFactory->create( $exprResult->getScope()->mergeWith($originalScope), + $originalScope, + $expr->expr, $exprResult->hasYield(), $isAlwaysTerminating, $exprResult->getThrowPoints(), $exprResult->getImpurePoints(), + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } @@ -95,97 +213,40 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex }, $expr instanceof Expr\AssignOp\Coalesce, ); - if (!$expr instanceof Expr\AssignOp\Coalesce) { - $nodeScopeResolver->storeBeforeScope($storage, $expr, $scope); - } $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->readStoredOrPriceOnDemand($expr->expr, $scope)->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()); } - return new ExpressionResult( + if ($expr instanceof Expr\AssignOp\Coalesce) { + // the ??= left side is processed as an assignment target, not a read, so + // it carries no isset descriptor; read it on demand so NullCoalesceRule + // gets the chain's IssetabilityResolution off the carried result + $varReadResult = $nodeScopeResolver->processExprOnDemand($expr->var, $beforeScope, new ExpressionResultStorage()); + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $varReadResult, 'on left side of ??='), $beforeScope, $storage, $context); + } + + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $assignResult->hasYield(), isAlwaysTerminating: $assignResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } - 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 0f604c86441..704ca888bbc 100644 --- a/src/Analyser/ExprHandler/BinaryOpHandler.php +++ b/src/Analyser/ExprHandler/BinaryOpHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use Closure; use Countable; use DivisionByZeroError; use PhpParser\Node\Expr; @@ -14,15 +15,16 @@ 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\EqualityTypeSpecifyingHelper; 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; @@ -66,6 +68,9 @@ public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, private ExprPrinter $exprPrinter, private EqualityTypeSpecifyingHelper $equalityTypeSpecifyingHelper, + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -83,492 +88,565 @@ 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; $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftResult->getScope(), $storage, $nodeCallback, $context->enterDeep()); $throwPoints = array_merge($leftResult->getThrowPoints(), $rightResult->getThrowPoints()); $impurePoints = array_merge($leftResult->getImpurePoints(), $rightResult->getImpurePoints()); if ( ($expr instanceof BinaryOp\Div || $expr instanceof BinaryOp\Mod) && - !$leftResult->getScope()->getType($expr->right)->toNumber()->isSuperTypeOf(new ConstantIntegerType(0))->no() + // the right operand was just processed on $leftResult's scope; read its + // result instead of re-walking via Scope::getType(). + !$rightResult->getTypeForScope($leftResult->getScope())->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 new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $leftResult->isAlwaysTerminating() || $rightResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } + 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 { + if ($e === $expr->left) { + return ($nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType()); + } + if ($e === $expr->right) { + return ($nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType()); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $getType = static fn (Expr $expr): Type => $scope->getType($expr); + return $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $beforeScope) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $beforeScope); + }; - 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)->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)->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 ($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(), - ); - } + 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))); + }, + specifyTypesCallback: function (MutatingScope $scope, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { + $resultFor = static fn (Expr $e): ?ExpressionResult => $e === $expr->left ? $leftResult : ($e === $expr->right ? $rightResult : null); + if ($expr instanceof BinaryOp\Identical) { + return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($nodeScopeResolver, $expr, $scope, $context, $resultFor); + } - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if ($expr instanceof BinaryOp\Identical) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForIdentical($expr, $scope, $context); - } + if ($expr instanceof BinaryOp\NotIdentical) { + // negating the context is exactly what a BooleanNot around the + // Identical would do - direct computation avoids synthesizing a + // BooleanNot node (on-demand re-processing once it is migrated). + // A null context never negates (BooleanNot defaults on it too). + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - if ($expr instanceof BinaryOp\NotIdentical) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new Expr\BooleanNot(new BinaryOp\Identical($expr->left, $expr->right)), - $context, - )->setRootExpr($expr); - } + return $this->defaultNarrowingHelper->specifyTypesForNode( + $scope, + new BinaryOp\Identical($expr->left, $expr->right), + $context->negate(), + )->setRootExpr($expr); + } - if ($expr instanceof BinaryOp\Equal) { - return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($expr, $scope, $context); - } + if ($expr instanceof BinaryOp\Equal) { + return $this->equalityTypeSpecifyingHelper->specifyTypesForEqual($nodeScopeResolver, $expr, $scope, $context, $resultFor); + } - 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\NotEqual) { + // see NotIdentical above + if ($context->null()) { + 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); - } + return $this->defaultNarrowingHelper->specifyTypesForNode( + $scope, + new BinaryOp\Equal($expr->left, $expr->right), + $context->negate(), + )->setRootExpr($expr); + } - $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()); + 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): Type { + if ($e === $expr->left) { + return $scope->nativeTypesPromoted ? $leftResult->getNativeType() : $leftResult->getType(); + } + if ($e === $expr->right) { + return $scope->nativeTypesPromoted ? $rightResult->getNativeType() : $rightResult->getType(); } - } elseif ($context->truthy() && $leftType->getMin() !== null) { - if ($orEqual) { - $sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin()); + + return $nodeScopeResolver->readStoredOrPriceOnDemand($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->typeSpecifier->specifyTypesForCountFuncCall($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 (!$countableInterface->isSuperTypeOf($innerType)->yes()) { + continue; + } + + $countables[] = $innerType; + } + } + + if (count($countables) > 0) { + $countableType = TypeCombinator::union(...$countables); - 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; + return $this->defaultNarrowingHelper->createForSubject($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr); } + } - if (!$countableInterface->isSuperTypeOf($innerType)->yes()) { - continue; + if ($argType->isArray()->yes()) { + $newType = new NonEmptyArrayType(); + if ($context->true() && $argType->isList()->yes()) { + $newType = TypeCombinator::intersect($newType, new AccessoryArrayListType()); } - $countables[] = $innerType; + $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->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( + $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), - ); + 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 $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), ['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(); + } - // 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), - ); - } - } + $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), ['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); - } + 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 ( - !$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 (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->getTypeForScope($scope); + $rightType = $rightResult->getTypeForScope($scope); - 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 de49fb09887..232bd9372fa 100644 --- a/src/Analyser/ExprHandler/BitwiseNotHandler.php +++ b/src/Analyser/ExprHandler/BitwiseNotHandler.php @@ -7,16 +7,16 @@ 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\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -28,6 +28,8 @@ final class BitwiseNotHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,23 +43,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/BooleanAndHandler.php b/src/Analyser/ExprHandler/BooleanAndHandler.php index 383b03c7d66..822ac22297f 100644 --- a/src/Analyser/ExprHandler/BooleanAndHandler.php +++ b/src/Analyser/ExprHandler/BooleanAndHandler.php @@ -4,32 +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\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; /** @@ -39,11 +32,9 @@ final class BooleanAndHandler implements ExprHandler { - private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; - public function __construct( - private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -53,183 +44,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 = $leftTypes->normalize($scope); - $rightNormalized = $rightTypes->normalize($rightScope); - $types = $leftNormalized->intersectWith($rightNormalized); - $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(), - ); - 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): int - { - while ( - $expr instanceof BooleanOr - || $expr instanceof LogicalOr - || $expr instanceof BooleanAnd - || $expr instanceof LogicalAnd - ) { - return self::getBooleanExpressionDepth($expr->left, $depth + 1); - } - - 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]) { @@ -262,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftTruthyScope = $leftResult->getTruthyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftTruthyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getScope()->getType($expr->right); + $rightExprType = $rightResult->getTypeForScope($rightResult->getScope()); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getFalseyScope(); } else { @@ -271,14 +85,109 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanAndNode($expr, $leftTruthyScope), $scope, $storage, $context); - return new ExpressionResult( + return $this->expressionResultFactory->create( $leftMergedWithRightScope, + beforeScope: $scope, + expr: $expr, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), 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: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { + $leftTypes = $leftResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + $rightScope = $leftResult->getTruthyScope(); + $rightTypes = $rightResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + if ($context->true()) { + $types = $leftTypes->unionWith($rightTypes); + } else { + $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); + $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftResult->getFalseyScope(), $rightResult->getFalseyScope(), $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 = $leftResult->getSpecifiedTypesForScope($s, TypeSpecifierContext::createFalsey())->setRootExpr($expr); + } + if ($rightHolderTypes->getSureTypes() === [] && $rightHolderTypes->getSureNotTypes() === []) { + $rightHolderTypes = $rightResult->getSpecifiedTypesForScope($rightScope, 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 = $leftResult->getSpecifiedTypesForScope($s, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyLeftTypes)) { + $leftCondTypes = new SpecifiedTypes($truthyLeftTypes->getSureNotTypes(), $truthyLeftTypes->getSureTypes()); + } + } + if ($rightCondTypes->getSureTypes() === [] && $rightCondTypes->getSureNotTypes() === []) { + $truthyRightTypes = $rightResult->getSpecifiedTypesForScope($rightScope, TypeSpecifierContext::createTruthy()); + if ($this->allExpressionsTrackable($truthyRightTypes)) { + $rightCondTypes = new SpecifiedTypes($truthyRightTypes->getSureNotTypes(), $truthyRightTypes->getSureTypes()); + } + } + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, false, true, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, false, true, $s, $expr->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftCondTypes, $rightHolderTypes, true, true, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightCondTypes, $leftHolderTypes, true, true, $s, $expr->left), + ]))->setRootExpr($expr); + } + + return $types; + }, ); } diff --git a/src/Analyser/ExprHandler/BooleanNotHandler.php b/src/Analyser/ExprHandler/BooleanNotHandler.php index 6bd2831fb27..d4986986099 100644 --- a/src/Analyser/ExprHandler/BooleanNotHandler.php +++ b/src/Analyser/ExprHandler/BooleanNotHandler.php @@ -7,13 +7,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\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; @@ -27,6 +27,13 @@ final class BooleanNotHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof BooleanNot; @@ -34,37 +41,39 @@ 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->enterDeep()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } + 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); + } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $exprBooleanType = $scope->getType($expr->expr)->toBoolean(); - if ($exprBooleanType instanceof ConstantBooleanType) { - return new ConstantBooleanType(!$exprBooleanType->getValue()); - } + return new BooleanType(); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } - 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 $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->getSpecifiedTypesForScope($s, $context->negate())->setRootExpr($expr); + }, + ); } } diff --git a/src/Analyser/ExprHandler/BooleanOrHandler.php b/src/Analyser/ExprHandler/BooleanOrHandler.php index d439a2c8082..cf813d4e4f9 100644 --- a/src/Analyser/ExprHandler/BooleanOrHandler.php +++ b/src/Analyser/ExprHandler/BooleanOrHandler.php @@ -8,19 +8,17 @@ 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\DefaultNarrowingHelper; 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; @@ -28,8 +26,6 @@ use PHPStan\Type\TypeCombinator; use function array_key_first; use function array_merge; -use function array_reverse; -use function count; /** * @implements ExprHandler @@ -38,11 +34,10 @@ final class BooleanOrHandler implements ExprHandler { - private const BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH = 4; - public function __construct( - private NodeScopeResolver $nodeScopeResolver, private ConditionalExpressionHolderHelper $conditionalExpressionHolderHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -52,168 +47,6 @@ public function supports(Expr $expr): bool return $expr instanceof BooleanOr || $expr instanceof LogicalOr; } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $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(); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } - - // For deep BooleanOr chains, flatten and process all arms at once - // to avoid O(n^2) recursive filterByFalseyValue calls - if (BooleanAndHandler::getBooleanExpressionDepth($expr) > self::BOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH) { - return $this->specifyTypesForFlattenedBooleanOr($typeSpecifier, $scope, $expr, $context); - } - - $leftTypes = $typeSpecifier->specifyTypesInCondition($scope, $expr->left, $context)->setRootExpr($expr); - $rightScope = $scope->filterByFalseyValue($expr->left); - $rightTypes = $typeSpecifier->specifyTypesInCondition($rightScope, $expr->right, $context)->setRootExpr($expr); - - if ($context->true()) { - if ( - $scope->getType($expr->left)->toBoolean()->isFalse()->yes() - ) { - $types = $rightTypes->normalize($rightScope); - } elseif ( - $scope->getType($expr->left)->toBoolean()->isTrue()->yes() - || $scope->getType($expr->right)->toBoolean()->isFalse()->yes() - ) { - $types = $leftTypes->normalize($scope); - } else { - $leftNormalized = $leftTypes->normalize($scope); - $rightNormalized = $rightTypes->normalize($rightScope); - $types = $leftNormalized->intersectWith($rightNormalized); - $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(), - ); - 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->normalize($scope); - } - - $types = $armSpecifiedTypes[0]; - for ($i = 1; $i < count($armSpecifiedTypes); $i++) { - $types = $types->intersectWith($armSpecifiedTypes[$i]); - } - - $result = new SpecifiedTypes( - $types->getSureTypes(), - $types->getSureNotTypes(), - ); - 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 @@ -232,11 +65,16 @@ 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 + private function augmentBooleanOrTruthyWithConditionalHolders( + NodeScopeResolver $nodeScopeResolver, + MutatingScope $scope, + MutatingScope $leftTruthyScope, + MutatingScope $rightScope, + MutatingScope $rightTruthyScope, + BooleanOr|LogicalOr $expr, + SpecifiedTypes $types, + ): SpecifiedTypes { - $leftTruthyScope = $scope->filterByTruthyValue($expr->left); - $rightTruthyScope = $rightScope->filterByTruthyValue($expr->right); - $seen = []; foreach ([$scope, $rightScope] as $sourceScope) { foreach ($sourceScope->getConditionalExpressions() as $exprString => $holders) { @@ -264,9 +102,9 @@ private function augmentBooleanOrTruthyWithConditionalHolders(TypeSpecifier $typ continue; } - $origType = $scope->getType($targetExpr); - $leftType = $leftTruthyScope->getType($targetExpr); - $rightType = $rightTruthyScope->getType($targetExpr); + $origType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $scope); + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $leftTruthyScope); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $rightTruthyScope); $leftNarrowed = !$leftType->equals($origType) && $origType->isSuperTypeOf($leftType)->yes(); $rightNarrowed = !$rightType->equals($origType) && $origType->isSuperTypeOf($rightType)->yes(); @@ -281,7 +119,7 @@ private function augmentBooleanOrTruthyWithConditionalHolders(TypeSpecifier $typ } $types = $types->unionWith( - $typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), + $this->defaultNarrowingHelper->createSubjectTypes($scope, $targetExpr, null, $unionType, TypeSpecifierContext::createTrue()), ); } } @@ -294,7 +132,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $leftResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $scope, $storage, $nodeCallback, $context->enterDeep()); $leftFalseyScope = $leftResult->getFalseyScope(); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $leftFalseyScope, $storage, $nodeCallback, $context); - $rightExprType = $rightResult->getScope()->getType($expr->right); + $rightExprType = $rightResult->getTypeForScope($rightResult->getScope()); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { $leftMergedWithRightScope = $leftResult->getTruthyScope(); } else { @@ -303,14 +141,95 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new BooleanOrNode($expr, $leftFalseyScope), $scope, $storage, $context); - return new ExpressionResult( + return $this->expressionResultFactory->create( $leftMergedWithRightScope, + beforeScope: $scope, + expr: $expr, hasYield: $leftResult->hasYield() || $rightResult->hasYield(), 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: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $leftResult, $rightResult, $nodeScopeResolver): SpecifiedTypes { + $leftTypes = $leftResult->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + $rightScope = $leftResult->getFalseyScope(); + $rightTypes = $rightResult->getSpecifiedTypesForScope($rightScope, $context)->setRootExpr($expr); + + if ($context->true()) { + if ( + $leftResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + ) { + $types = $rightTypes->normalize($rightScope, $nodeScopeResolver); + } elseif ( + $leftResult->getTypeForScope($s)->toBoolean()->isTrue()->yes() + || $rightResult->getTypeForScope($s)->toBoolean()->isFalse()->yes() + ) { + $types = $leftTypes->normalize($s, $nodeScopeResolver); + } else { + $leftNormalized = $leftTypes->normalize($s, $nodeScopeResolver); + $rightNormalized = $rightTypes->normalize($rightScope, $nodeScopeResolver); + $types = $leftNormalized->intersectWith($rightNormalized); + $types = $this->augmentBooleanOrTruthyWithConditionalHolders( + $nodeScopeResolver, + $s, + $leftResult->getTruthyScope(), + $rightScope, + $rightResult->getTruthyScope(), + $expr, + $types); + $types = $this->conditionalExpressionHolderHelper->augmentDisjunctionTypes($nodeScopeResolver, $s, $leftNormalized, $rightNormalized, $leftResult->getTruthyScope(), $rightResult->getTruthyScope(), $types); + } + } else { + $types = $leftTypes->unionWith($rightTypes); + } + + if ($context->true()) { + $result = new SpecifiedTypes( + $types->getSureTypes(), + $types->getSureNotTypes(), + ); + if ($types->shouldOverwrite()) { + $result = $result->setAlwaysOverwriteTypes(); + } + return $result->setNewConditionalExpressionHolders($this->conditionalExpressionHolderHelper->mergeConditionalHolders([ + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, false, false, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, false, false, $s, $expr->left), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $leftTypes, $rightTypes, true, false, $rightScope, $expr->right), + $this->conditionalExpressionHolderHelper->processBooleanConditionalTypes($nodeScopeResolver, $s, $rightTypes, $leftTypes, true, false, $s, $expr->left), + ]))->setRootExpr($expr); + } + + return $types; + }, ); } diff --git a/src/Analyser/ExprHandler/CastHandler.php b/src/Analyser/ExprHandler/CastHandler.php index cbc3d70fcb0..f472b261e83 100644 --- a/src/Analyser/ExprHandler/CastHandler.php +++ b/src/Analyser/ExprHandler/CastHandler.php @@ -13,16 +13,17 @@ 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\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\NullType; use PHPStan\Type\Type; @@ -35,6 +36,8 @@ final class CastHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -46,56 +49,47 @@ 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->enterDeep()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } + 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 (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + if ($expr instanceof Cast\Bool_) { + return $s->obtainResultForNode(new Equal($expr->expr, new ConstFetch(new FullyQualified('true'))))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + } - 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\Int_) { + return $s->obtainResultForNode(new NotEqual($expr->expr, new Int_(0)))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + } - if ($expr instanceof Cast\Double) { - return $typeSpecifier->specifyTypesInCondition( - $scope, - new NotEqual($expr->expr, new Float_(0.0)), - $context, - )->setRootExpr($expr); - } + if ($expr instanceof Cast\Double) { + return $s->obtainResultForNode(new NotEqual($expr->expr, new Float_(0.0)))->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + } - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + }, + ); } } diff --git a/src/Analyser/ExprHandler/CastStringHandler.php b/src/Analyser/ExprHandler/CastStringHandler.php index 4fdfc9adfc6..102ae599a5f 100644 --- a/src/Analyser/ExprHandler/CastStringHandler.php +++ b/src/Analyser/ExprHandler/CastStringHandler.php @@ -9,17 +9,17 @@ 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\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; @@ -33,6 +33,7 @@ final class CastStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -44,39 +45,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 { + $beforeScope = $scope; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $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()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } - - 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: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $s->obtainResultForNode( + new NotEqual($expr->expr, new String_('')), + )->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr), + ); } } diff --git a/src/Analyser/ExprHandler/ClassConstFetchHandler.php b/src/Analyser/ExprHandler/ClassConstFetchHandler.php index 25199ed14f7..6321d4eb7e7 100644 --- a/src/Analyser/ExprHandler/ClassConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ClassConstFetchHandler.php @@ -8,16 +8,16 @@ 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\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\MixedType; use PHPStan\Type\Type; use function array_merge; @@ -31,6 +31,8 @@ final class ClassConstFetchHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -40,27 +42,15 @@ 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; $hasYield = false; $throwPoints = []; $impurePoints = []; $isAlwaysTerminating = false; + $classResult = null; if ($expr->class instanceof Expr) { $classResult = $nodeScopeResolver->processExprNode($stmt, $expr->class, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $classResult->getScope(); @@ -83,20 +73,41 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $nameResult->isAlwaysTerminating(); } - return new ExpressionResult( + // 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, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } + 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 (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/CloneHandler.php b/src/Analyser/ExprHandler/CloneHandler.php index 9d2f1bf6574..2aeec17852e 100644 --- a/src/Analyser/ExprHandler/CloneHandler.php +++ b/src/Analyser/ExprHandler/CloneHandler.php @@ -7,14 +7,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\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; @@ -29,6 +28,13 @@ final class CloneHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Clone_; @@ -38,24 +44,20 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context) => $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 cc70889d379..591fcc32e2a 100644 --- a/src/Analyser/ExprHandler/ClosureHandler.php +++ b/src/Analyser/ExprHandler/ClosureHandler.php @@ -7,17 +7,15 @@ 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\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 @@ -28,6 +26,8 @@ final class ClosureHandler implements ExprHandler public function __construct( private ClosureTypeResolver $closureTypeResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -40,25 +40,45 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $processClosureResult = $nodeScopeResolver->processClosureNode($stmt, $expr, $scope, $storage, $nodeCallback, $context, null); - $scope = $processClosureResult->applyByRefUseScope($processClosureResult->getScope()); - return new ExpressionResult( + // 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, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $c) => $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 eb566e0166d..22175f08bd6 100644 --- a/src/Analyser/ExprHandler/CoalesceHandler.php +++ b/src/Analyser/ExprHandler/CoalesceHandler.php @@ -7,17 +7,17 @@ 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\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; @@ -34,6 +34,8 @@ final class CoalesceHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -43,100 +45,119 @@ public function supports(Expr $expr): bool return $expr instanceof Coalesce; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * A falsey coalesce means its left side was null (when it was surely set) - + * shared by the specifyTypesCallback and by processExpr() for the scope + * the right side evaluates under. + * + * @param Coalesce $expr + */ + private function getFalseySpecifiedTypes(MutatingScope $s, Expr $expr, ExpressionResult $condResult, TypeSpecifierContext $context): SpecifiedTypes { - $issetLeftExpr = new Expr\Isset_([$expr->left]); + $isset = $condResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); - $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 ($isset !== true) { + return new SpecifiedTypes(); } - 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 ((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); + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, new NullType(), $context->negate())->setRootExpr($expr); } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->left); + $beforeScope = $scope; + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $expr->left); $condScope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->left); $condResult = $nodeScopeResolver->processExprNode($stmt, $expr->left, $condScope, $storage, $nodeCallback, $context->enterDeep()); $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->getFalseySpecifiedTypes($scope, $expr, $condResult, TypeSpecifierContext::createFalsey())); $rightResult = $nodeScopeResolver->processExprNode($stmt, $expr->right, $rightScope, $storage, $nodeCallback, $context->enterDeep()); - $rightExprType = $scope->getType($expr->right); + $rightExprType = $rightResult->getTypeForScope($scope); if ($rightExprType instanceof NeverType && $rightExprType->isExplicit()) { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left])); + $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Expr\Isset_([$expr->left]), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy())); } else { - $scope = $scope->filterByTruthyValue(new Expr\Isset_([$expr->left]))->mergeWith($rightResult->getScope()); + $scope = $scope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Expr\Isset_([$expr->left]), $scope, new ExpressionResultStorage())->getSpecifiedTypesForScope($scope, TypeSpecifierContext::createTruthy()))->mergeWith($rightResult->getScope()); } - return new ExpressionResult( + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new CoalesceExpressionNode($expr, $condResult, 'on left side of ??'), $beforeScope, $storage, $context); + + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $condResult->hasYield() || $rightResult->hasYield(), isAlwaysTerminating: $condResult->isAlwaysTerminating(), throwPoints: array_merge($condResult->getThrowPoints(), $rightResult->getThrowPoints()), impurePoints: array_merge($condResult->getImpurePoints(), $rightResult->getImpurePoints()), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $condResult, $rightResult, $nodeScopeResolver, $beforeScope): Type { + $issetLeftExpr = new Expr\Isset_([$expr->left]); + + // the isset resolution and the left-is-set narrowing run on + // beforeScope (the evaluation point), not the asking scope. + $result = $condResult->getIssetabilityResolution($beforeScope, false)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } + + return !$isNull->yes(); + }); + + if ($result !== null && $result !== false) { + return TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()); + } + + // 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( + TypeCombinator::removeNull($nodeScopeResolver->processExprOnDemand($expr->left, $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand($issetLeftExpr, $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())), new ExpressionResultStorage())->getType()), + $rightType, + ); + } + + return $rightType; + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + if (!$context->true()) { + return $this->getFalseySpecifiedTypes($s, $expr, $condResult, $context); + } + + if ((new ConstantBooleanType(false))->isSuperTypeOf($rightResult->getTypeForScope($s)->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 (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $condResult, $rightResult): SpecifiedTypes { + if (!$context->null()) { + $rightType = $rightResult->getTypeForScope($s); + if ( + ($context->true() && $type->isSuperTypeOf($rightType)->no()) + || ($context->false() && $type->isSuperTypeOf($rightType)->yes()) + ) { + return $this->defaultNarrowingHelper->createSubjectTypes($s, $expr->left, $condResult, $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 ba5ae2c71e1..2e0e9d2a549 100644 --- a/src/Analyser/ExprHandler/ConstFetchHandler.php +++ b/src/Analyser/ExprHandler/ConstFetchHandler.php @@ -9,13 +9,12 @@ use PHPStan\Analyser\ConstantResolver; 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\Type\Constant\ConstantBooleanType; @@ -33,6 +32,8 @@ final class ConstFetchHandler implements ExprHandler public function __construct( private ConstantResolver $constantResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -46,59 +47,53 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $nodeScopeResolver->callNodeCallback($nodeCallback, $expr->name, $scope, $storage); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } - - 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 (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/EmptyHandler.php b/src/Analyser/ExprHandler/EmptyHandler.php index 185850e6e6f..44a03927041 100644 --- a/src/Analyser/ExprHandler/EmptyHandler.php +++ b/src/Analyser/ExprHandler/EmptyHandler.php @@ -8,17 +8,17 @@ 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\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; @@ -32,6 +32,8 @@ final class EmptyHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,65 +43,45 @@ 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 { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($scope, $expr->expr); + $beforeScope = $scope; + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $scope, $expr->expr); $scope = $nodeScopeResolver->lookForSetAllowedUndefinedExpressions($nonNullabilityResult->getScope(), $expr->expr); $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $exprResult->getScope(); $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $expr->expr); - return new ExpressionResult( + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new EmptyExpressionNode($expr, $exprResult), $beforeScope, $storage, $context); + + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: $exprResult->isAlwaysTerminating(), throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + 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 (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult): SpecifiedTypes { + $isset = $exprResult->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); + if ($isset === false) { + return new SpecifiedTypes(); + } + + return $this->defaultNarrowingHelper->specifyTypesForNode($s, new BooleanOr( + new Expr\BooleanNot(new Expr\Isset_([$expr->expr])), + new Expr\BooleanNot($expr->expr), + ), $context)->setRootExpr($expr); + }, ); } diff --git a/src/Analyser/ExprHandler/ErrorSuppressHandler.php b/src/Analyser/ExprHandler/ErrorSuppressHandler.php index 0e2e47fee9c..4399034b59e 100644 --- a/src/Analyser/ExprHandler/ErrorSuppressHandler.php +++ b/src/Analyser/ExprHandler/ErrorSuppressHandler.php @@ -7,13 +7,12 @@ 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\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; @@ -25,6 +24,12 @@ final class ErrorSuppressHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ErrorSuppress; @@ -32,27 +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 new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), + 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: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $exprResult->getSpecifiedTypesForScope($s, $context)->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 c2e635c4880..45089f75dbd 100644 --- a/src/Analyser/ExprHandler/EvalHandler.php +++ b/src/Analyser/ExprHandler/EvalHandler.php @@ -7,15 +7,14 @@ 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\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; @@ -29,33 +28,35 @@ final class EvalHandler implements ExprHandler { - public function supports(Expr $expr): bool + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { - return $expr instanceof Eval_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function supports(Expr $expr): bool { - return new MixedType(); + return $expr instanceof Eval_; } 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->enterDeep()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context) => $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 7734d8dea34..62a0ddb7baa 100644 --- a/src/Analyser/ExprHandler/ExitHandler.php +++ b/src/Analyser/ExprHandler/ExitHandler.php @@ -7,14 +7,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\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; @@ -28,6 +27,13 @@ final class ExitHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Exit_; @@ -35,6 +41,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $kind = $expr->getAttribute('kind', Exit_::KIND_EXIT); $identifier = $kind === Exit_::KIND_DIE ? 'die' : 'exit'; $impurePoints = [ @@ -51,23 +58,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $exprResult->getScope(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: true, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $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 e5c57469d8b..7d8b152d3b2 100644 --- a/src/Analyser/ExprHandler/FuncCallHandler.php +++ b/src/Analyser/ExprHandler/FuncCallHandler.php @@ -15,10 +15,14 @@ use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt; use PHPStan\Analyser\ArgumentsNormalizer; +use PHPStan\Analyser\ArgsResult; 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\VoidToNullTypeTransformer; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; @@ -97,6 +101,10 @@ public function __construct( private bool $implicitThrows, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { } @@ -108,23 +116,33 @@ 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; $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) { - $nameType = $scope->getType($expr->name); + // 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->getTypeForScope($scope); 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); } - $nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep()); $scope = $nameResult->getScope(); $throwPoints = $nameResult->getThrowPoints(); $impurePoints = $nameResult->getImpurePoints(); @@ -147,12 +165,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(), - ); + $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); $impurePoint = SimpleImpurePoint::createFromVariant($functionReflection, $parametersAcceptor, $scope, $expr->getArgs()); if ($impurePoint !== null) { $impurePoints[] = new ImpurePoint($scope, $expr, $impurePoint->getIdentifier(), $impurePoint->getDescription(), $impurePoint->isCertain()); @@ -180,7 +198,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->getTypeForScope($scope); $cloneExpr = new TypeExpr($scope->getType(new Expr\Clone_($normalizedExpr->getArgs()[0]->value))); $clonePropertiesArgTypeConstantArrays = $clonePropertiesArgType->getConstantArrays(); foreach ($clonePropertiesArgTypeConstantArrays as $clonePropertiesArgTypeConstantArray) { @@ -238,8 +262,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($firstParamName !== null) { $arrayWalkArrayArg = $normalizedExpr->getArgs()[0]->value; - $arrayWalkOriginalArrayType = $scope->getType($arrayWalkArrayArg); - $arrayWalkOriginalArrayNativeType = $scope->getNativeType($arrayWalkArrayArg); $nodeCallbackForArgs = static function (Node $node, Scope $scope) use ($nodeCallback, $callbackArg, $firstParamName, &$arrayWalkValueTypes): void { if ($node instanceof ClosureReturnStatementsNode && $node->getClosureExpr() === $callbackArg) { @@ -275,14 +297,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } $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 ($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); @@ -298,6 +324,63 @@ 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 (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $s, + $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 = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $this->isFuncCallNarrowable($nodeScopeResolver, $s, $expr, $nameResult) + ? $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $createContext) + : new SpecifiedTypes([], []); + + // 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; the final result below + // overwrites it with the resolved scope and throw/impure points. + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, + )); + if ($normalizedExpr->name instanceof Expr) { $nameType = $scope->getType($normalizedExpr->name); if ( @@ -320,7 +403,25 @@ 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 via readStoredOrPriceOnDemand, + // 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. + $returnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $nameResult, $resolvedParametersAcceptor, $argsResult); + // 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; } @@ -404,8 +505,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(); @@ -440,13 +541,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->getTypeForScope($scope) : $scope->getType($arrayArg); + $arrayArgNativeType = $arrayArgResult !== null ? $arrayArgResult->getNativeTypeForScope($scope) : $scope->getNativeType($arrayArg); - $offsetType = $scopeBeforeArgs->getType($normalizedExpr->getArgs()[1]->value); + $offsetArg = $normalizedExpr->getArgs()[1]->value; + $offsetArgResult = $argsResult->getArgResult($offsetArg); + $offsetType = $offsetArgResult !== null ? $offsetArgResult->getTypeForScope($scopeBeforeArgs) : $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->getTypeForScope($scopeBeforeArgs) : $scopeBeforeArgs->getType($lengthArg); } else { $lengthType = new NullType(); } @@ -597,20 +703,24 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } private function getFunctionThrowPoint( FunctionReflection $functionReflection, ?ParametersAcceptor $parametersAcceptor, + Type $returnType, FuncCall $normalizedFuncCall, MutatingScope $scope, ExpressionContext $context, @@ -631,7 +741,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); } @@ -659,8 +768,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); } } @@ -669,19 +777,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->getTypeForScope($scope->toMutatingScope()) : $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->getTypeForScope($scope->toMutatingScope()) : $scope->getType($callArg->value); if ($callArg->unpack) { $constantArrays = $callArgType->getConstantArrays(); if (count($constantArrays) === 1) { @@ -812,20 +924,56 @@ static function (?Type $offsetType, Type $valueType, bool $optional) use (&$arra return $arrayType; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + /** + * The call-expression type is derived from $preResolvedAcceptor - the acceptor + * processArgs() selected from the arg types gathered on the arg-to-arg evolving + * scope (type-driven, generics resolved). When null (native-types-promoted, or + * a callable callee whose name was processed elsewhere), it falls back to a + * structural acceptor combined from the variants - generic resolution from the + * actual arg types lives in $preResolvedAcceptor, recomputed by on-demand / + * synthetic pricing that re-runs processArgs(). + * + * @param FuncCall $expr + */ + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, Expr $expr, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor, ArgsResult $argsResult): Type { + // 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 $nativeTypesPromoted + ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($e, $reflectionScope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $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_) { @@ -841,9 +989,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); if ($resolvedType !== null) { return $resolvedType; } @@ -852,45 +1000,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) { @@ -911,7 +1061,7 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return $cloneType; } - $resolvedType = $this->getDynamicFunctionReturnType($scope, $normalizedNode, $functionReflection); + $resolvedType = $this->getDynamicFunctionReturnType($reflectionScope, $normalizedNode, $functionReflection); if ($resolvedType !== null) { return $resolvedType; } @@ -920,22 +1070,26 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type return VoidToNullTypeTransformer::transform($parametersAcceptor->getReturnType(), $expr); } - 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; } @@ -943,56 +1097,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->typeSpecifier->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->typeSpecifier->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->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($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(); } @@ -1009,7 +1169,65 @@ private function specifyTypesFromCallableCall(TypeSpecifier $typeSpecifier, Type TemplateTypeVariance::createInvariant(), )); - return $typeSpecifier->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); + return $this->typeSpecifier->specifyTypesFromAsserts($context, $call, $asserts, $parametersAcceptor, $scope); + } + + /** + * 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 + { + if (!$this->isFuncCallNarrowable($nodeScopeResolver, $scope, $expr, $nameResult)) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + + 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->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($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): ?Type diff --git a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php index ff165386f75..7d5f59d4e0a 100644 --- a/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php +++ b/src/Analyser/ExprHandler/Helper/ClosureTypeResolver.php @@ -11,6 +11,7 @@ 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\Scope; @@ -26,6 +27,7 @@ use PHPStan\Reflection\Callables\SimpleThrowPoint; use PHPStan\Reflection\ExtendedParameterReflection; use PHPStan\Reflection\Native\NativeParameterReflection; +use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\Php\DummyParameter; use PHPStan\ShouldNotHappenException; @@ -38,6 +40,7 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NonAcceptingNeverType; use PHPStan\Type\NullType; +use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\VoidType; use function array_key_exists; @@ -58,111 +61,45 @@ public function __construct( { } + /** + * 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); - } - } - } - if ($expr instanceof ArrowFunction) { $arrowScope = $scope->enterArrowFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - if ($expr->expr instanceof Yield_ || $expr->expr instanceof YieldFrom) { - $yieldNode = $expr->expr; - - if ($yieldNode instanceof Yield_) { - if ($yieldNode->key === null) { - $keyType = new IntegerType(); - } else { - $keyType = $arrowScope->getType($yieldNode->key); - } - - 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); - } - - $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 = []; $invalidateExpressions = []; $arrowFunctionExprResult = $this->nodeScopeResolver->processExprNode( @@ -197,214 +134,531 @@ static function (Node $node, Scope $scope) use ($arrowScope, &$arrowFunctionImpu ); $throwPoints = array_map(static fn ($throwPoint) => $throwPoint->toPublic(), $arrowFunctionExprResult->getThrowPoints()); $impurePoints = array_merge($arrowFunctionImpurePoints, $arrowFunctionExprResult->getImpurePoints()); - $usedVariables = []; - } 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; - } - } + + // 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); + + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, []); + } + + $cachedTypes = $expr->getAttribute('phpstanCachedTypes', []); + $cacheKey = $scope->getClosureScopeCacheKey(); + 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), + ); + } + + self::$resolveClosureTypeDepth++; + + $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; } - 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), - ); - } - if (self::$resolveClosureTypeDepth >= 2) { - return new ClosureType( - $parameters, - $scope->getFunctionType($expr->returnType, false, false), - $isVariadic, - isStatic: TrinaryLogic::createFromBoolean($expr->static), - ); - } + if ($node instanceof InvalidateExprNode) { + $invalidateExpressions[] = $node; + return; + } - self::$resolveClosureTypeDepth++; + if ($node instanceof PropertyAssignNode) { + $closureImpurePoints[] = new ImpurePoint( + $scope, + $node, + 'propertyAssign', + 'property assignment', + true, + ); + $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); + return; + } - $closureScope = $scope->enterAnonymousFunctionWithoutReflection($expr, $callableParameters, $nativeCallableParameters); - $closureReturnStatements = []; - $closureYieldStatements = []; - $onlyNeverExecutionEnds = null; - $closureImpurePoints = []; - $invalidateExpressions = []; + if ($node instanceof ExecutionEndNode) { + $closureExecutionEnds[] = $node; + return; + } - 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; - } + if ($node instanceof Node\Stmt\Return_) { + $closureReturnStatements[] = [$node, $scope]; + } - if ($node instanceof InvalidateExprNode) { - $invalidateExpressions[] = $node; - return; - } + if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { + return; + } - if ($node instanceof PropertyAssignNode) { - $closureImpurePoints[] = new ImpurePoint( - $scope, - $node, - 'propertyAssign', - 'property assignment', - true, - ); - $invalidateExpressions[] = new InvalidateExprNode($node->getPropertyFetch()); - return; - } + $closureYieldStatements[] = [$node, $scope]; + }, StatementContext::createTopLevel()); + } finally { + self::$resolveClosureTypeDepth--; + } - 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; - } + $throwPoints = $closureStatementResult->getThrowPoints(); + $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); - return; - } + return $this->buildClosureTypeFromClosureWalk( + $scope, + $expr, + $parameters, + $isVariadic, + $closureReturnStatements, + $closureYieldStatements, + $closureExecutionEnds, + $throwPoints, + $impurePoints, + $invalidateExpressions, + ); + } - if ($node instanceof Node\Stmt\Return_) { - $closureReturnStatements[] = [$node, $scope]; - } + /** + * 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, + ): ClosureType + { + if ($this->bodyWalkHasOwnParameterTypes($expr)) { + return $this->getClosureType($scope, $expr); + } - if (!$node instanceof Yield_ && !$node instanceof YieldFrom) { - return; - } + [$parameters, $isVariadic] = $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, + ); + } + + /** + * 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] = $this->buildParametersAndAcceptors($scope, $expr); + + $returnType = $this->resolveArrowFunctionReturnType($scope, $arrowScope, $expr, $native); + + return $this->assembleClosureType($scope, $expr, $parameters, $isVariadic, $returnType, $throwPoints, $impurePoints, $invalidateExpressions, []); + } + + /** + * Whether getClosureType() would walk the body with different parameter types + * than NodeScopeResolver's single walk (processClosureNode()/ + * processArrowFunctionNode()) did. array_map() callbacks and immediately + * invoked closures get their parameter types from the array element type / + * the invocation arguments in getClosureType(), whereas the single walk types + * them from the closure's passed-to callable type - so the return type read + * from the gathered scopes would differ, and getClosureType() must re-walk. + */ + private function bodyWalkHasOwnParameterTypes(Node\Expr\Closure|ArrowFunction $expr): bool + { + return $expr->getAttribute(ArrayMapArgVisitor::ATTRIBUTE_NAME) !== null + || $expr->getAttribute(ImmediatelyInvokedClosureVisitor::ARGS_ATTRIBUTE_NAME) !== null; + } + + /** + * @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, + ): ClosureType + { + $onlyNeverExecutionEnds = $this->deriveOnlyNeverExecutionEnds($executionEnds); + + $returnTypes = []; + $hasNull = false; + foreach ($returnStatements as [$returnNode, $returnScope]) { + if ($returnNode->expr === null) { + $hasNull = true; + continue; + } + + $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); + } - $closureYieldStatements[] = [$node, $scope]; - }, StatementContext::createTopLevel()); - } finally { - self::$resolveClosureTypeDepth--; + 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); + } - $throwPoints = $closureStatementResult->getThrowPoints(); - $impurePoints = array_merge($closureImpurePoints, $closureStatementResult->getImpurePoints()); + if (count($yieldStatements) > 0) { + $keyTypes = []; + $valueTypes = []; + foreach ($yieldStatements as [$yieldNode, $yieldScope]) { + if ($yieldNode instanceof Yield_) { + if ($yieldNode->key === null) { + $keyTypes[] = new IntegerType(); + } else { + $keyTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->key); + } + + if ($yieldNode->value === null) { + $valueTypes[] = new NullType(); + } else { + $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); + } - $returnTypes = []; - $hasNull = false; - foreach ($closureReturnStatements as [$returnNode, $returnScope]) { - if ($returnNode->expr === null) { - $hasNull = true; continue; } - $returnTypes[] = $returnScope->toMutatingScope()->getType($returnNode->expr); + $yieldFromType = $yieldScope->toMutatingScope()->getType($yieldNode->expr); + $keyTypes[] = $yieldScope->toMutatingScope()->getIterableKeyType($yieldFromType); + $valueTypes[] = $yieldScope->toMutatingScope()->getIterableValueType($yieldFromType); + } + + $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); } + } + + $usedVariables = []; + foreach ($expr->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + + $usedVariables[] = $use->var->name; + } + + foreach ($expr->uses as $use) { + if (!$use->byRef) { + continue; + } + + $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); + } - if (count($returnTypes) === 0) { - if ($onlyNeverExecutionEnds === true && !$hasNull) { - $returnType = new NonAcceptingNeverType(); + 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(); - } - } else { - if ($onlyNeverExecutionEnds === true) { - $returnTypes[] = new NonAcceptingNeverType(); + $keyType = $readScope->getType($yieldNode->key); } - 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); - } - - if ($yieldNode->value === null) { - $valueTypes[] = new NullType(); - } else { - $valueTypes[] = $yieldScope->toMutatingScope()->getType($yieldNode->value); - } + return new GenericObjectType(Generator::class, [ + $keyType, + $valueType, + new MixedType(), + new VoidType(), + ]); + } + $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, + $param->default !== null ? $scope->getType($param->default) : 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 $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, + ): ClosureType + { foreach ($parameters as $parameter) { if ($parameter->passedByReference()->no()) { continue; diff --git a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php index 125089656b1..ef1e2a6928f 100644 --- a/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php +++ b/src/Analyser/ExprHandler/Helper/ConditionalExpressionHolderHelper.php @@ -10,9 +10,8 @@ use PHPStan\Analyser\ConditionalExpressionHolder; use PHPStan\Analyser\ExpressionTypeHolder; 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; @@ -30,19 +29,18 @@ final class ConditionalExpressionHolderHelper { public function __construct( - private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } public function augmentDisjunctionTypes( + NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, - MutatingScope $rightScope, SpecifiedTypes $leftNormalized, SpecifiedTypes $rightNormalized, - Expr $leftExpr, - Expr $rightExpr, - bool $truthy, + MutatingScope $leftFilteredScope, + MutatingScope $rightFilteredScope, SpecifiedTypes $types, ): SpecifiedTypes { @@ -71,14 +69,6 @@ public function augmentDisjunctionTypes( 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; @@ -87,9 +77,11 @@ public function augmentDisjunctionTypes( continue; } - $originalType = $scope->getType($targetExpr); - $leftType = $leftFilteredScope->getType($targetExpr); - $rightType = $rightFilteredScope->getType($targetExpr); + // the operands were processed during processExpr; read their stored + // results on these filtered scopes instead of re-walking via getType(). + $originalType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $scope); + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $leftFilteredScope); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($targetExpr, $rightFilteredScope); if ($leftType->equals($originalType) || !$originalType->isSuperTypeOf($leftType)->yes()) { continue; @@ -105,7 +97,7 @@ public function augmentDisjunctionTypes( } $types = $types->unionWith( - $this->typeSpecifier->create($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), + $this->defaultNarrowingHelper->createForSubject($targetExpr, $unionType, TypeSpecifierContext::createTrue(), $scope), ); } @@ -140,7 +132,7 @@ public function mergeConditionalHolders(array $holderLists): array /** * @return array */ - public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $conditionSpecifiedTypes, SpecifiedTypes $holderSpecifiedTypes, bool $holdersFromSureTypes, bool $holderSideIsNegated, Scope $rightScope, ?Expr $holderSideExpr = null): array + public function processBooleanConditionalTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, SpecifiedTypes $conditionSpecifiedTypes, SpecifiedTypes $holderSpecifiedTypes, bool $holdersFromSureTypes, bool $holderSideIsNegated, MutatingScope $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`), @@ -155,7 +147,7 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con continue; } - $scopeType = $scope->getType($expr); + $scopeType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); $conditionType = TypeCombinator::remove($scopeType, $type); if ($scopeType->equals($conditionType)) { continue; @@ -171,7 +163,7 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con continue; } - $scopeType = $scope->getType($expr); + $scopeType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); $conditionType = TypeCombinator::intersect($scopeType, $type); if ($scopeType->equals($conditionType)) { continue; @@ -220,7 +212,7 @@ public function processBooleanConditionalTypes(Scope $scope, SpecifiedTypes $con } $targetScope = $expr instanceof Expr\Variable ? $scope : $rightScope; - $targetType = $targetScope->getType($expr); + $targetType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $targetScope); $holderType = $holdersFromSureTypes ? TypeCombinator::intersect($targetType, $type) : TypeCombinator::remove($targetType, $type); diff --git a/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php new file mode 100644 index 00000000000..24e6ef3bf27 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/DefaultNarrowingHelper.php @@ -0,0 +1,133 @@ +` - 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, + private TypeSpecifier $typeSpecifier, + ) + { + } + + /** + * 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); + } + + /** + * A greatly simplified TypeSpecifier::create() for a subject the calling + * handler has already processed: one sure (truthy) or sureNot (falsey) + * entry for the subject node. A coalesce subject narrows its left side + * when the narrowed type rules the right side in or out. No purity gates, + * no nullsafe chain-walking, no assignment fan-out - an entry about an + * assignment narrows the assigned variables in the appliers, and the + * subject's own narrowing composes in through + * ExpressionResult::getSpecifiedTypesForScope() at the call site. + */ + /** + * A greatly simplified 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 a single sure (truthy) or sureNot (falsey) entry + * for the subject node is emitted. No purity gates, no nullsafe + * chain-walking, no structural unwrapping - the handlers that own those + * nodes compose their children's results inside-out. + */ + 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; + } + } + + // No composable result (a synthetic node, or a subject whose handler wired + // no createTypesCallback): fall back to the raw-Expr create(), which does the + // structural fan-out (assignment / remembered wrapper) and createForExpr. For + // a plain subject this equals the single sure/sureNot entry it used to emit. + return $this->typeSpecifier->create($subject, $type, $context, $s); + } + + /** + * 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; + + return $this->createSubjectTypes( + $mutatingScope, + $subject, + $subjectResult ?? $mutatingScope->getCurrentExpressionResultStorage()?->findExpressionResult($subject), + $type, + $context, + ); + } + +} diff --git a/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php b/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php new file mode 100644 index 00000000000..d44df0587f3 --- /dev/null +++ b/src/Analyser/ExprHandler/Helper/EarlyTerminatingCallHelper.php @@ -0,0 +1,80 @@ + */ + private array $earlyTerminatingMethodNames; + + /** + * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) + * @param array $earlyTerminatingFunctionCalls + */ + public function __construct( + private ReflectionProvider $reflectionProvider, + #[AutowiredParameter] + private array $earlyTerminatingMethodCalls, + #[AutowiredParameter] + private array $earlyTerminatingFunctionCalls, + ) + { + $earlyTerminatingMethodNames = []; + foreach ($this->earlyTerminatingMethodCalls as $methodNames) { + foreach ($methodNames as $methodName) { + $earlyTerminatingMethodNames[strtolower($methodName)] = true; + } + } + $this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames; + } + + public function isEarlyTerminatingMethodCall(string $methodName, Type $calledOnType): bool + { + if (!array_key_exists(strtolower($methodName), $this->earlyTerminatingMethodNames)) { + return false; + } + + foreach ($calledOnType->getObjectClassNames() as $referencedClass) { + if (!$this->reflectionProvider->hasClass($referencedClass)) { + continue; + } + + $classReflection = $this->reflectionProvider->getClass($referencedClass); + foreach (array_merge([$referencedClass], $classReflection->getParentClassesNames(), $classReflection->getNativeReflection()->getInterfaceNames()) as $className) { + if (!isset($this->earlyTerminatingMethodCalls[$className])) { + continue; + } + + if (in_array($methodName, $this->earlyTerminatingMethodCalls[$className], true)) { + return true; + } + } + } + + return false; + } + + public function isEarlyTerminatingFunctionCall(string $functionName): bool + { + return in_array($functionName, $this->earlyTerminatingFunctionCalls, true); + } + +} diff --git a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php index c5162b0830e..99195644f45 100644 --- a/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php +++ b/src/Analyser/ExprHandler/Helper/EqualityTypeSpecifyingHelper.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler\Helper; +use Closure; use Countable; use PhpParser\Node; use PhpParser\Node\Expr; @@ -10,6 +11,8 @@ use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\Instanceof_; use PhpParser\Node\Name; +use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; use PHPStan\Analyser\SpecifiedTypes; use PHPStan\Analyser\TypeSpecifier; @@ -63,13 +66,18 @@ public function __construct( private TypeSpecifier $typeSpecifier, private ReflectionProvider $reflectionProvider, private ExprPrinter $exprPrinter, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } - public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + + /** + * @param Closure(Expr): ?ExpressionResult $resultFor + */ + public function specifyTypesForEqual(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Equal $expr, Scope $scope, TypeSpecifierContext $context, Closure $resultFor): SpecifiedTypes { - $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); + $expressions = $this->findTypeExpressionsFromBinaryOperation($nodeScopeResolver, $scope, $expr); if ($expressions !== null) { $exprNode = $expressions[0]; $constantType = $expressions[1]; @@ -84,21 +92,19 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty new ConstantStringType(''), new ConstantArrayType([], []), ]; - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === false) { - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - $exprNode, + return ($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( + $scope->toMutatingScope(), $context->true() ? TypeSpecifierContext::createFalsey() : TypeSpecifierContext::createFalsey()->negate(), )->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === true) { - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - $exprNode, + return ($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( + $scope->toMutatingScope(), $context->true() ? TypeSpecifierContext::createTruthy() : TypeSpecifierContext::createTruthy()->negate(), )->setRootExpr($expr); } @@ -124,7 +130,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty new ConstantStringType('0'), ]; } - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); } if (!$context->null() && $constantType->getValue() === '') { @@ -146,7 +152,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty new ConstantStringType(''), ]; } - return $this->typeSpecifier->create($exprNode, new UnionType($trueTypes), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($exprNode, new UnionType($trueTypes), $context, $scope, $resultFor)->setRootExpr($expr); } if ( @@ -157,7 +163,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty && isset($exprNode->getArgs()[0]) && $constantType->isString()->yes() ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } if ( @@ -167,7 +173,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty && $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); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } if ( @@ -177,16 +183,18 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty && strtolower($exprNode->name->toString()) === 'class' && $constantType->isString()->yes() ) { - return $this->typeSpecifier->specifyTypesInCondition($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + // the operands were processed during processExpr; read their stored results + // instead of re-walking via Scope::getType(). + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->left, $scope->toMutatingScope()); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->right, $scope->toMutatingScope()); $leftBooleanType = $leftType->toBoolean(); if ($leftBooleanType instanceof ConstantBooleanType && $rightType->isBoolean()->yes()) { - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new Expr\BinaryOp\Identical( new ConstFetch(new Name($leftBooleanType->getValue() ? 'true' : 'false')), @@ -198,7 +206,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty $rightBooleanType = $rightType->toBoolean(); if ($rightBooleanType instanceof ConstantBooleanType && $leftType->isBoolean()->yes()) { - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new Expr\BinaryOp\Identical( $expr->left, @@ -213,7 +221,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty && $rightType->isArray()->yes() && $leftType->isConstantArray()->yes() && $leftType->isIterableAtLeastOnce()->no() ) { - return $this->typeSpecifier->create($expr->right, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($expr->right, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); } if ( @@ -221,7 +229,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty && $leftType->isArray()->yes() && $rightType->isConstantArray()->yes() && $rightType->isIterableAtLeastOnce()->no() ) { - return $this->typeSpecifier->create($expr->left, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($expr->left, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); } if ( @@ -230,7 +238,7 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty || ($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); + return $this->defaultNarrowingHelper->specifyTypesForNode($scope, new Expr\BinaryOp\Identical($expr->left, $expr->right), $context)->setRootExpr($expr); } $leftExprString = $this->exprPrinter->printExpr($expr->left); @@ -241,46 +249,52 @@ public function specifyTypesForEqual(Expr\BinaryOp\Equal $expr, Scope $scope, Ty } } - $leftTypes = $this->typeSpecifier->create($expr->left, $leftType, $context, $scope)->setRootExpr($expr); - $rightTypes = $this->typeSpecifier->create($expr->right, $rightType, $context, $scope)->setRootExpr($expr); + $leftTypes = $this->defaultNarrowingHelper->createForSubject($expr->left, $leftType, $context, $scope, $resultFor)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($expr->right, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); return $context->true() ? $leftTypes->unionWith($rightTypes) - : $leftTypes->normalize($scope)->intersectWith($rightTypes->normalize($scope)); + : $leftTypes->normalize($scope, $nodeScopeResolver)->intersectWith($rightTypes->normalize($scope, $nodeScopeResolver)); } - public function specifyTypesForIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + /** + * @param Closure(Expr): ?ExpressionResult $resultFor + */ + public function specifyTypesForIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context, Closure $resultFor): 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( + $specifiedTypes = $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $rightExpr, $leftExpr, - ), $scope, $context); + ), $scope, $context, $resultFor); } else { - $specifiedTypes = $this->specifyTypesForNormalizedIdentical(new Expr\BinaryOp\Identical( + $specifiedTypes = $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $leftExpr, $rightExpr, - ), $scope, $context); + ), $scope, $context, $resultFor); } // merge result of fn1() === fn2() and fn2() === fn1() if ($rightExpr instanceof FuncCall && $leftExpr instanceof FuncCall) { return $specifiedTypes->unionWith( - $this->specifyTypesForNormalizedIdentical(new Expr\BinaryOp\Identical( + $this->specifyTypesForNormalizedIdentical($nodeScopeResolver, new Expr\BinaryOp\Identical( $rightExpr, $leftExpr, - ), $scope, $context), + ), $scope, $context, $resultFor), ); } return $specifiedTypes; } - private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + /** + * @param Closure(Expr): ?ExpressionResult $resultFor + */ + private function specifyTypesForNormalizedIdentical(NodeScopeResolver $nodeScopeResolver, Expr\BinaryOp\Identical $expr, Scope $scope, TypeSpecifierContext $context, Closure $resultFor): SpecifiedTypes { $leftExpr = $expr->left; $rightExpr = $expr->right; @@ -294,7 +308,11 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp $unwrappedRightExpr = $rightExpr->getExpr(); } - $rightType = $scope->getType($rightExpr); + // the operands and their subexpressions were processed during processExpr; + // read their stored results instead of re-walking via Scope::getType(). + $getType = static fn (Expr $e): Type => $nodeScopeResolver->readStoredOrPriceOnDemand($e, $scope->toMutatingScope()); + + $rightType = $getType($rightExpr); // (count($a) === $expr) if ( @@ -315,37 +333,37 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp && in_array($unwrappedRightExpr->name->toLowerString(), ['count', 'sizeof'], true) && count($unwrappedRightExpr->getArgs()) >= 1 ) { - $argType = $scope->getType($unwrappedRightExpr->getArgs()[0]->value); - $sizeType = $scope->getType($leftExpr); + $argType = $getType($unwrappedRightExpr->getArgs()[0]->value); + $sizeType = $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); + $leftArrayType = $getType($unwrappedLeftExpr->getArgs()[0]->value); + $rightArrayType = $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); + $arrayTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr); return $arrayTypes->unionWith( - $this->typeSpecifier->create($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedRightExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr), ); } } if (IntegerRangeType::fromInterval(null, -1)->isSuperTypeOf($rightType)->yes()) { - return $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope, $resultFor)->setRootExpr($expr); } - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); if ($context->truthy() && !$argType->isArray()->yes()) { $newArgType = new UnionType([ @@ -357,24 +375,24 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp } return $funcTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $newArgType, $context, $scope, $resultFor)->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); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); return $specifiedTypes->unionWith($funcTypes); } return $specifiedTypes; } if ($context->truthy() && $argType->isArray()->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->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), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NonEmptyArrayType(), $context, $scope, $resultFor)->setRootExpr($expr), ); } @@ -393,27 +411,27 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp && $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); + return $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new NeverType(), $context, $scope, $resultFor)->setRootExpr($expr); } $isZero = (new ConstantIntegerType(0))->isSuperTypeOf($rightType); if ($isZero->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); return $funcTypes->unionWith( - $this->typeSpecifier->create($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, new ConstantStringType(''), $context, $scope, $resultFor)->setRootExpr($expr), ); } if ($context->truthy() && IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($rightType)->yes()) { - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $argType = $getType($unwrappedLeftExpr->getArgs()[0]->value); if ($argType->isString()->yes()) { - $funcTypes = $this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + $funcTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->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); + $valueTypes = $this->defaultNarrowingHelper->createForSubject($unwrappedLeftExpr->getArgs()[0]->value, $accessory, $context, $scope, $resultFor)->setRootExpr($expr); return $funcTypes->unionWith($valueTypes); } @@ -435,13 +453,13 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp $notNullOnly = $funcName === 'array_find_key'; if ($bothDirections || $notNullOnly) { $args = $unwrappedLeftExpr->getArgs(); - $argType = $scope->getType($args[0]->value); + $argType = $getType($args[0]->value); if ($argType->isArray()->yes()) { if ($bothDirections) { - return $this->typeSpecifier->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); } if ($context->falsey()) { - return $this->typeSpecifier->create($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope)->setRootExpr($expr); + return $this->defaultNarrowingHelper->createForSubject($args[0]->value, new NonEmptyArrayType(), $context->negate(), $scope, $resultFor)->setRootExpr($expr); } } } @@ -455,9 +473,8 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() ) { - return $this->typeSpecifier->specifyTypesInCondition( - $scope, - $leftExpr, + return ($resultFor($leftExpr) ?? $scope->toMutatingScope()->obtainResultForNode($leftExpr))->getSpecifiedTypesForScope( + $scope->toMutatingScope(), $context, )->setRootExpr($expr); } @@ -473,20 +490,22 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp ) { $constantStringTypes = $rightType->getConstantStrings(); if (count($constantStringTypes) === 1 && $this->reflectionProvider->hasClass($constantStringTypes[0]->getValue())) { - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( $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); + $resultFor, + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); } if ($rightType->getClassStringObjectType()->isObject()->yes()) { - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, $rightType->getClassStringObjectType(), $context, $scope, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + $resultFor, + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); } } @@ -503,41 +522,45 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp && isset($unwrappedLeftExpr->getArgs()[0]) && $rightType->isNonEmptyString()->yes() ) { - $argType = $scope->getType($unwrappedLeftExpr->getArgs()[0]->value); + $argType = $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( + $specifiedTypes = $this->defaultNarrowingHelper->createForSubject( $unwrappedRightExpr, TypeCombinator::intersect($rightType, new AccessoryLowercaseStringType()), $context, $scope, + $resultFor, )->setRootExpr($expr); } if (in_array(strtolower($unwrappedLeftExpr->name->toString()), ['strtoupper', 'mb_strtoupper'], true)) { - $specifiedTypes = $this->typeSpecifier->create( + $specifiedTypes = $this->defaultNarrowingHelper->createForSubject( $unwrappedRightExpr, TypeCombinator::intersect($rightType, new AccessoryUppercaseStringType()), $context, $scope, + $resultFor, )->setRootExpr($expr); } if ($rightType->isNonFalsyString()->yes()) { - return $specifiedTypes->unionWith($this->typeSpecifier->create( + return $specifiedTypes->unionWith($this->defaultNarrowingHelper->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, TypeCombinator::intersect($argType, new AccessoryNonFalsyStringType()), $context, $scope, + $resultFor, )->setRootExpr($expr)); } - return $specifiedTypes->unionWith($this->typeSpecifier->create( + return $specifiedTypes->unionWith($this->defaultNarrowingHelper->createForSubject( $unwrappedLeftExpr->getArgs()[0]->value, TypeCombinator::intersect($argType, new AccessoryNonEmptyStringType()), $context, $scope, + $resultFor, )->setRootExpr($expr)); } } @@ -545,7 +568,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp if ($rightType->isString()->yes()) { $types = null; foreach ($rightType->getConstantStrings() as $constantString) { - $specifiedType = $this->specifyTypesForConstantStringBinaryExpression($unwrappedLeftExpr, $constantString, $context, $scope, $expr); + $specifiedType = $this->specifyTypesForConstantStringBinaryExpression($nodeScopeResolver, $unwrappedLeftExpr, $constantString, $context, $scope, $expr); if ($specifiedType === null) { continue; @@ -560,13 +583,13 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp if ($types !== null) { if ($leftExpr !== $unwrappedLeftExpr) { - $types = $types->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr)); + $types = $types->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr)); } return $types; } } - $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); + $expressions = $this->findTypeExpressionsFromBinaryOperation($nodeScopeResolver, $scope, $expr); if ($expressions !== null) { $exprNode = $expressions[0]; $constantType = $expressions[1]; @@ -576,11 +599,11 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp $unwrappedExprNode = $exprNode->getExpr(); } - $specifiedType = $this->specifyTypesForConstantBinaryExpression($unwrappedExprNode, $constantType, $context, $scope, $expr); + $specifiedType = $this->specifyTypesForConstantBinaryExpression($unwrappedExprNode, $constantType, $context, $scope, $expr, $resultFor); if ($specifiedType !== null) { if ($exprNode !== $unwrappedExprNode) { $specifiedType = $specifiedType->unionWith( - $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($expr), ); } return $specifiedType; @@ -599,25 +622,26 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp $constantStrings = $rightType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( $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); + $resultFor, + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); } - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new Instanceof_( $unwrappedLeftExpr->class, new Name($constantStrings[0]->getValue()), ), $context, - )->unionWith($this->typeSpecifier->create($leftExpr, $rightType, $context, $scope))->setRootExpr($expr); + )->unionWith($this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor))->setRootExpr($expr); } } - $leftType = $scope->getType($leftExpr); + $leftType = $getType($leftExpr); // 'Foo' === $a::class if ( @@ -631,40 +655,33 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp $constantStrings = $leftType->getConstantStrings(); if (count($constantStrings) === 1 && $constantStrings[0]->getValue() !== '') { if ($this->reflectionProvider->hasClass($constantStrings[0]->getValue())) { - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( $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)); + $resultFor, + )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)); } - return $this->typeSpecifier->specifyTypesInCondition( + return $this->defaultNarrowingHelper->specifyTypesForNode( $scope, new Instanceof_( $unwrappedRightExpr->class, new Name($constantStrings[0]->getValue()), ), $context, - )->unionWith($this->typeSpecifier->create($rightExpr, $leftType, $context, $scope)->setRootExpr($expr)); + )->unionWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)); } } if ($context->false()) { - $identicalType = $scope->getType($expr); + $identicalType = $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); - } + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $never, $contextForTypes, $scope, $resultFor)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $never, $contextForTypes, $scope, $resultFor)->setRootExpr($expr); return $leftTypes->unionWith($rightTypes); } } @@ -678,20 +695,13 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp && !$rightType->equals($leftType) && $rightType->isSuperTypeOf($leftType)->yes()) ) { - $types = $this->typeSpecifier->create( + $types = $this->defaultNarrowingHelper->createForSubject( $rightExpr, $leftType, $context, $scope, + $resultFor, )->setRootExpr($expr); - if ($rightExpr instanceof AlwaysRememberedExpr) { - $types = $types->unionWith($this->typeSpecifier->create( - $unwrappedRightExpr, - $leftType, - $context, - $scope, - ))->setRootExpr($expr); - } } if ( count($rightType->getFiniteTypes()) === 1 @@ -702,20 +712,7 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp && $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); - } + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); if ($types !== null) { $types = $types->unionWith($leftTypes); } else { @@ -736,22 +733,12 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp } 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), - ); - } + $leftTypes = $this->defaultNarrowingHelper->createForSubject($leftExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr); + $rightTypes = $this->defaultNarrowingHelper->createForSubject($rightExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr); return $leftTypes->unionWith($rightTypes); } elseif ($context->false()) { - return $this->typeSpecifier->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope) - ->intersectWith($this->typeSpecifier->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope)); + return $this->defaultNarrowingHelper->createForSubject($leftExpr, $leftType, $context, $scope, $resultFor)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver) + ->intersectWith($this->defaultNarrowingHelper->createForSubject($rightExpr, $rightType, $context, $scope, $resultFor)->setRootExpr($expr)->normalize($scope, $nodeScopeResolver)); } return (new SpecifiedTypes([], []))->setRootExpr($expr); @@ -760,10 +747,12 @@ private function specifyTypesForNormalizedIdentical(Expr\BinaryOp\Identical $exp /** * @return array{Expr, ConstantScalarType, Type}|null */ - private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array + private function findTypeExpressionsFromBinaryOperation(NodeScopeResolver $nodeScopeResolver, Scope $scope, Node\Expr\BinaryOp $binaryOperation): ?array { - $leftType = $scope->getType($binaryOperation->left); - $rightType = $scope->getType($binaryOperation->right); + // the operands were processed during processExpr; read their stored results + // instead of re-walking via Scope::getType(). + $leftType = $nodeScopeResolver->readStoredOrPriceOnDemand($binaryOperation->left, $scope->toMutatingScope()); + $rightType = $nodeScopeResolver->readStoredOrPriceOnDemand($binaryOperation->right, $scope->toMutatingScope()); $rightExpr = $binaryOperation->right; if ($rightExpr instanceof AlwaysRememberedExpr) { @@ -790,36 +779,38 @@ private function findTypeExpressionsFromBinaryOperation(Scope $scope, Node\Expr\ return null; } + /** + * @param Closure(Expr): ?ExpressionResult $resultFor + */ private function specifyTypesForConstantBinaryExpression( Expr $exprNode, Type $constantType, TypeSpecifierContext $context, Scope $scope, Expr $rootExpr, + Closure $resultFor, ): ?SpecifiedTypes { if (!$context->null() && $constantType->isFalse()->yes()) { - $types = $this->typeSpecifier->create($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($rootExpr); if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { return $types; } - return $types->unionWith($this->typeSpecifier->specifyTypesInCondition( - $scope, - $exprNode, + return $types->unionWith(($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( + $scope->toMutatingScope(), $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); + $types = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope, $resultFor)->setRootExpr($rootExpr); if (!$context->true() && ($exprNode instanceof Expr\NullsafeMethodCall || $exprNode instanceof Expr\NullsafePropertyFetch)) { return $types; } - return $types->unionWith($this->typeSpecifier->specifyTypesInCondition( - $scope, - $exprNode, + return $types->unionWith(($resultFor($exprNode) ?? $scope->toMutatingScope()->obtainResultForNode($exprNode))->getSpecifiedTypesForScope( + $scope->toMutatingScope(), $context->true() ? TypeSpecifierContext::createTrue() : TypeSpecifierContext::createTrue()->negate(), )->setRootExpr($rootExpr)); } @@ -828,6 +819,7 @@ private function specifyTypesForConstantBinaryExpression( } private function specifyTypesForConstantStringBinaryExpression( + NodeScopeResolver $nodeScopeResolver, Expr $exprNode, Type $constantType, TypeSpecifierContext $context, @@ -875,8 +867,8 @@ private function specifyTypesForConstantStringBinaryExpression( } 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); + $callType = $this->defaultNarrowingHelper->createForSubject($exprNode, $constantType, $context, $scope)->setRootExpr($rootExpr); + $argType = $this->defaultNarrowingHelper->createForSubject($exprNode->getArgs()[0]->value, $type, $context, $scope)->setRootExpr($rootExpr); return $callType->unionWith($argType); } } @@ -889,12 +881,14 @@ private function specifyTypesForConstantStringBinaryExpression( && strtolower((string) $exprNode->name) === 'get_parent_class' && isset($exprNode->getArgs()[0]) ) { - $argType = $scope->getType($exprNode->getArgs()[0]->value); + // the argument was processed during processExpr; read its stored result + // instead of re-walking via Scope::getType(). + $argType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprNode->getArgs()[0]->value, $scope->toMutatingScope()); $objectType = new ObjectType($constantStringValue); $classStringType = new GenericClassStringType($objectType); if ($argType->isString()->yes()) { - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( $exprNode->getArgs()[0]->value, $classStringType, $context, @@ -903,7 +897,7 @@ private function specifyTypesForConstantStringBinaryExpression( } if ($argType->isObject()->yes()) { - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( $exprNode->getArgs()[0]->value, $objectType, $context, @@ -911,7 +905,7 @@ private function specifyTypesForConstantStringBinaryExpression( )->setRootExpr($rootExpr); } - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( $exprNode->getArgs()[0]->value, TypeCombinator::union($objectType, $classStringType), $context, @@ -932,9 +926,11 @@ private function specifyTypesForConstantStringBinaryExpression( && $constantStringValue === '' ) { $argValue = $exprNode->getArgs()[0]->value; - $argType = $scope->getType($argValue); + // the argument was processed during processExpr; read its stored result + // instead of re-walking via Scope::getType(). + $argType = $nodeScopeResolver->readStoredOrPriceOnDemand($argValue, $scope->toMutatingScope()); if ($argType->isString()->yes()) { - return $this->typeSpecifier->create( + return $this->defaultNarrowingHelper->createForSubject( $argValue, new IntersectionType([ new StringType(), diff --git a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php index eab62846f88..cf79506826b 100644 --- a/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php +++ b/src/Analyser/ExprHandler/Helper/ImplicitToStringCallHelper.php @@ -2,12 +2,16 @@ namespace PHPStan\Analyser\ExprHandler\Helper; +use PHPStan\Analyser\SpecifiedTypes; +use PHPStan\Type\MixedType; use PhpParser\Node\Expr; use PhpParser\Node\Identifier; use PHPStan\Analyser\ExpressionContext; use PHPStan\Analyser\ExpressionResult; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ImpurePoint; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Php\PhpVersion; use function sprintf; @@ -19,28 +23,42 @@ final class ImplicitToStringCallHelper public function __construct( private PhpVersion $phpVersion, private MethodThrowPointHelper $methodThrowPointHelper, + private ExpressionResultFactory $expressionResultFactory, ) { } - 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->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($expr, $scope); $toStringMethod = null; if (!$exprType->isObject()->no()) { $toStringMethod = $scope->getMethodReflection($exprType, '__toString'); } if ($toStringMethod === null) { - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } @@ -55,24 +73,32 @@ 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->priceSyntheticOnDemand($toStringCall, $scope), ); if ($throwPoint !== null) { $throwPoints[] = $throwPoint; } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } diff --git a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php index da565b78d8d..b34386598ce 100644 --- a/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodCallReturnTypeHelper.php @@ -8,6 +8,7 @@ use PHPStan\Analyser\MutatingScope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\DependencyInjection\Type\DynamicReturnTypeExtensionRegistryProvider; +use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -29,6 +30,7 @@ public function methodCallReturnType( Type $typeWithMethod, string $methodName, MethodCall|Expr\StaticCall $methodCall, + ?ParametersAcceptor $preResolvedAcceptor = null, ): ?Type { $typeWithMethod = $scope->filterTypeWithMethod($typeWithMethod, $methodName); @@ -37,7 +39,7 @@ public function methodCallReturnType( } $methodReflection = $typeWithMethod->getMethod($methodName, $scope); - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $parametersAcceptor = $preResolvedAcceptor ?? ParametersAcceptorSelector::selectFromArgs( $scope, $methodCall->getArgs(), $methodReflection->getVariants(), diff --git a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php index 08ff8735587..a786153f2d0 100644 --- a/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php +++ b/src/Analyser/ExprHandler/Helper/MethodThrowPointHelper.php @@ -14,6 +14,7 @@ use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; +use PHPStan\Type\Type; use ReflectionFunction; use ReflectionMethod; use Throwable; @@ -31,12 +32,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) { @@ -77,8 +85,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); } } @@ -88,8 +95,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 79f5fe0b67c..3545dd35491 100644 --- a/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php +++ b/src/Analyser/ExprHandler/Helper/NonNullabilityHelper.php @@ -11,7 +11,7 @@ use PHPStan\Analyser\EnsuredNonNullabilityResult; use PHPStan\Analyser\EnsuredNonNullabilityResultExpression; use PHPStan\Analyser\MutatingScope; -use PHPStan\Analyser\Scope; +use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\TrinaryLogic; use PHPStan\Type\TypeCombinator; @@ -20,9 +20,12 @@ final class NonNullabilityHelper { - public function ensureShallowNonNullability(MutatingScope $scope, Scope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult + public function ensureShallowNonNullability(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, MutatingScope $originalScope, Expr $exprToSpecify): EnsuredNonNullabilityResult { - $exprType = $scope->getType($exprToSpecify); + // the expression has not been processed into the storage yet (this runs + // before processExprNode), so read its type from the stored result or + // price it on demand instead of re-walking via Scope::getType(). + $exprType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $scope); $isNull = $exprType->isNull(); if ($isNull->yes()) { return new EnsuredNonNullabilityResult($scope, []); @@ -32,9 +35,9 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $exprTypeWithoutNull = TypeCombinator::removeNull($exprType); if ($exprType->equals($exprTypeWithoutNull)) { - $originalExprType = $originalScope->getType($exprToSpecify); + $originalExprType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $originalScope); if (!$originalExprType->equals($exprTypeWithoutNull)) { - $originalNativeType = $originalScope->getNativeType($exprToSpecify); + $originalNativeType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $originalScope->doNotTreatPhpDocTypesAsCertain()); return new EnsuredNonNullabilityResult($scope, [ new EnsuredNonNullabilityResultExpression($exprToSpecify, $originalExprType, $originalNativeType, $hasExpressionType), @@ -52,8 +55,8 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $parentExpr = $exprToSpecify->var; $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression( $parentExpr, - $scope->getType($parentExpr), - $scope->getNativeType($parentExpr), + $nodeScopeResolver->readStoredOrPriceOnDemand($parentExpr, $scope), + $nodeScopeResolver->readStoredOrPriceOnDemand($parentExpr, $scope->doNotTreatPhpDocTypesAsCertain()), $originalScope->hasExpressionType($parentExpr), ); } @@ -64,7 +67,7 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina $certainty = $hasExpressionType; } - $nativeType = $scope->getNativeType($exprToSpecify); + $nativeType = $nodeScopeResolver->readStoredOrPriceOnDemand($exprToSpecify, $scope->doNotTreatPhpDocTypesAsCertain()); $specifiedExpressions[] = new EnsuredNonNullabilityResultExpression($exprToSpecify, $exprType, $nativeType, $certainty); $scope = $scope->specifyExpressionType( $exprToSpecify, @@ -79,17 +82,17 @@ public function ensureShallowNonNullability(MutatingScope $scope, Scope $origina ); } - public function ensureNonNullability(MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult + public function ensureNonNullability(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Expr $expr): EnsuredNonNullabilityResult { $specifiedExpressions = []; $originalScope = $scope; - $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope) { - $result = $this->ensureShallowNonNullability($scope, $originalScope, $expr); + $scope = $this->lookForExpressionCallback($scope, $expr, function ($scope, $expr) use (&$specifiedExpressions, $originalScope, $nodeScopeResolver) { + $result = $this->ensureShallowNonNullability($nodeScopeResolver, $scope, $originalScope, $expr); foreach ($result->getSpecifiedExpressions() as $specifiedExpression) { $specifiedExpressions[] = $specifiedExpression; } return $result->getScope(); - }); + }, false); return new EnsuredNonNullabilityResult($scope, $specifiedExpressions); } @@ -118,9 +121,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 72710dd93b5..00000000000 --- a/src/Analyser/ExprHandler/Helper/NullsafeShortCircuitingHelper.php +++ /dev/null @@ -1,54 +0,0 @@ -getType($expr->var); - if (TypeCombinator::containsNull($varType)) { - return TypeCombinator::addNull($type); - } - - return $type; - } - - if ($expr instanceof ArrayDimFetch) { - return self::getType($scope, $expr->var, $type); - } - - if ($expr instanceof PropertyFetch) { - return self::getType($scope, $expr->var, $type); - } - - if ($expr instanceof StaticPropertyFetch && $expr->class instanceof Expr) { - return self::getType($scope, $expr->class, $type); - } - - if ($expr instanceof MethodCall) { - return self::getType($scope, $expr->var, $type); - } - - if ($expr instanceof StaticCall && $expr->class instanceof Expr) { - return self::getType($scope, $expr->class, $type); - } - - return $type; - } - -} diff --git a/src/Analyser/ExprHandler/IncludeHandler.php b/src/Analyser/ExprHandler/IncludeHandler.php index 788a7c16522..b5da7680e0d 100644 --- a/src/Analyser/ExprHandler/IncludeHandler.php +++ b/src/Analyser/ExprHandler/IncludeHandler.php @@ -7,15 +7,14 @@ 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\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,34 +29,36 @@ final class IncludeHandler implements ExprHandler { - public function supports(Expr $expr): bool + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { - return $expr instanceof Include_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function supports(Expr $expr): bool { - return new MixedType(); + return $expr instanceof Include_; } 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->enterDeep()); $identifier = in_array($expr->type, [Include_::TYPE_INCLUDE, Include_::TYPE_INCLUDE_ONCE], true) ? 'include' : 'require'; $scope = $exprResult->getScope()->afterExtractCall(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context) => $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 36933e466b3..e45628e1d3b 100644 --- a/src/Analyser/ExprHandler/InstanceofHandler.php +++ b/src/Analyser/ExprHandler/InstanceofHandler.php @@ -8,15 +8,16 @@ 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\ShouldNotHappenException; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\MixedType; @@ -38,6 +39,13 @@ final class InstanceofHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Instanceof_; @@ -45,12 +53,14 @@ 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->enterDeep()); $hasYield = $exprResult->hasYield(); $throwPoints = $exprResult->getThrowPoints(); $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(); @@ -60,112 +70,133 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $classResult->isAlwaysTerminating(); } - return new ExpressionResult( - $scope, - hasYield: $hasYield, - isAlwaysTerminating: $isAlwaysTerminating, - throwPoints: $throwPoints, - impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } - - 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; - + // 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) { - $unresolvedClassName = $expr->class->toString(); - if ( - strtolower($unresolvedClassName) === 'static' - && $scope->isInClass() - ) { - $classType = new StaticType($scope->getClassReflection()); + if (strtolower($expr->class->toString()) === 'static' && $beforeScope->isInClass()) { + $nameClassType = new StaticType($beforeScope->getClassReflection()); } else { - $className = $scope->resolveName($expr->class); - $classType = new ObjectType($className); + $nameClassType = new ObjectType($beforeScope->resolveName($expr->class)); } - } else { - $result = $scope->getType($expr->class)->toObjectTypeForInstanceofCheck(); - $classType = $result->type; - $uncertainty = $result->uncertainty; - } - if ($classType->isSuperTypeOf(new MixedType())->yes()) { - return new BooleanType(); - } - - $isSuperType = $classType->isSuperTypeOf($expressionType); - - if ($isSuperType->no()) { - return new ConstantBooleanType(false); - } elseif ($isSuperType->yes() && !$uncertainty) { - return new ConstantBooleanType(true); - } - - return new BooleanType(); - } - - 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()); + 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 ( - $scope->isInClass() - && $scope->getClassReflection()->getParentClass() !== null + $beforeScope->isInClass() + && $beforeScope->getClassReflection()->getParentClass() !== null ) { - $type = new ObjectType($scope->getClassReflection()->getParentClass()->getName()); + $nameNarrowType = new ObjectType($beforeScope->getClassReflection()->getParentClass()->getName()); } else { - $type = new NonexistentParentClassType(); + $nameNarrowType = new NonexistentParentClassType(); } } else { - $type = new ObjectType($className); + $nameNarrowType = 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); + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + 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 ($context->true()) { - return $typeSpecifier->create($exprNode, new ObjectWithoutClassType(), $context, $scope)->setRootExpr($exprNode); - } + if ($expressionType instanceof NeverType) { + return new ConstantBooleanType(false); + } + + $uncertainty = false; + + 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; + } + + if ($classType->isSuperTypeOf(new MixedType())->yes()) { + return new BooleanType(); + } + + $isSuperType = $classType->isSuperTypeOf($expressionType); - return (new SpecifiedTypes([], []))->setRootExpr($expr); + if ($isSuperType->no()) { + return new ConstantBooleanType(false); + } elseif ($isSuperType->yes() && !$uncertainty) { + return new ConstantBooleanType(true); + } + + return new BooleanType(); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $exprResult, $classResult, $nameNarrowType): SpecifiedTypes { + $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); + } + + // 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->getTypeForScope($s); + $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->getTypeForScope($s); + 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); + } + + return (new SpecifiedTypes([], []))->setRootExpr($expr); + }, + ); } } diff --git a/src/Analyser/ExprHandler/InterpolatedStringHandler.php b/src/Analyser/ExprHandler/InterpolatedStringHandler.php index 51de44f579f..b784767573d 100644 --- a/src/Analyser/ExprHandler/InterpolatedStringHandler.php +++ b/src/Analyser/ExprHandler/InterpolatedStringHandler.php @@ -8,20 +8,20 @@ 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\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 @@ -33,6 +33,8 @@ final class InterpolatedStringHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -44,20 +46,24 @@ 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; $hasYield = false; $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()); @@ -65,38 +71,35 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $partResult->getScope(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, 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 (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/IssetHandler.php b/src/Analyser/ExprHandler/IssetHandler.php index da8e48431fd..7dd2f5fd08e 100644 --- a/src/Analyser/ExprHandler/IssetHandler.php +++ b/src/Analyser/ExprHandler/IssetHandler.php @@ -15,19 +15,21 @@ 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\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\Node\IssetExpressionNode; use PHPStan\Rules\Arrays\AllowedArrayKeysTypes; use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\HasOffsetType; @@ -49,6 +51,7 @@ use function array_shift; use function count; use function is_string; +use function spl_object_id; /** * @implements ExprHandler @@ -59,6 +62,9 @@ final class IssetHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -68,332 +74,369 @@ public function supports(Expr $expr): bool return $expr instanceof Isset_; } - 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 { - $issetResult = true; + $beforeScope = $scope; + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + $nonNullabilityResults = []; + $isAlwaysTerminating = false; + $varResults = []; foreach ($expr->vars as $var) { - $result = $scope->issetCheck($var, static function (Type $type): ?bool { - $isNull = $type->isNull(); - if ($isNull->maybe()) { - return null; - } + $nonNullabilityResult = $this->nonNullabilityHelper->ensureNonNullability($nodeScopeResolver, $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()); + $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); + $isAlwaysTerminating = $isAlwaysTerminating || $varResult->isAlwaysTerminating(); + $nonNullabilityResults[] = $nonNullabilityResult; - return !$isNull->yes(); - }); - if ($result !== null) { - if (!$result) { - return new ConstantBooleanType($result); - } + if (!($var instanceof ArrayDimFetch)) { + continue; + } + $varType = $nodeScopeResolver->readStoredOrPriceOnDemand($var->var, $scope); + if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { continue; } - $issetResult = $result; + $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( + $stmt, + new MethodCall(new TypeExpr($varType), 'offsetExists'), + $scope, + $storage, + new NoopNodeCallback(), + $context, + )->getThrowPoints()); + } + foreach (array_reverse($expr->vars) as $var) { + $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $var); + } + foreach (array_reverse($nonNullabilityResults) as $nonNullabilityResult) { + $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); } - if ($issetResult === null) { - return new BooleanType(); + // 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 + // getTypeForScope() instead of re-walking through Scope::getType(). + $chainResults = []; + foreach ($expr->vars as $var) { + $this->captureChainResults($var, $storage, $chainResults); } - return new ConstantBooleanType($issetResult); - } + $nodeScopeResolver->callNodeCallbackWithExpression($nodeCallback, new IssetExpressionNode($expr, $varResults), $beforeScope, $storage, $context); - 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); - } + return $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: $throwPoints, + impurePoints: $impurePoints, + typeCallback: static function (bool $nativeTypesPromoted) use ($varResults, $beforeScope): Type { + $issetResult = true; + foreach ($varResults as $varResult) { + $result = $varResult->getIssetabilityResolution($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, false)->isSet(static function (Type $type): ?bool { + $isNull = $type->isNull(); + if ($isNull->maybe()) { + return null; + } - // 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()); - } + return !$isNull->yes(); + }); + if ($result !== null) { + if (!$result) { + return new ConstantBooleanType($result); + } - $first = array_shift($issets); - $andChain = null; - foreach ($issets as $isset) { - if ($andChain === null) { - $andChain = new BooleanAnd($first, $isset); - continue; + continue; + } + + $issetResult = $result; } - $andChain = new BooleanAnd($andChain, $isset); - } + if ($issetResult === null) { + return new BooleanType(); + } - if ($andChain === null) { - throw new ShouldNotHappenException(); - } + return new ConstantBooleanType($issetResult); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $varResults, $chainResults, $nodeScopeResolver): SpecifiedTypes { + // type of an already-processed chain link, read from its captured + // result (re-evaluated on the asking scope, honouring narrowing) - + // never re-walked through the scope + $readType = static function (Expr $e) use ($chainResults, $s, $nodeScopeResolver): Type { + $result = $chainResults[spl_object_id($e)] ?? null; - return $typeSpecifier->specifyTypesInCondition($scope, $andChain, $context)->setRootExpr($expr); - } + return $result !== null ? $result->getTypeForScope($s) : $nodeScopeResolver->readStoredOrPriceOnDemand($e, $s); + }; - $issetExpr = $expr->vars[0]; - - if (!$context->true()) { - if (!$scope instanceof MutatingScope) { - throw new ShouldNotHappenException(); - } + if (count($expr->vars) === 0 || $context->null()) { + return $this->typeSpecifier->specifyDefaultTypes($s, $expr, $context); + } - $isset = $scope->issetCheck($issetExpr, static fn () => true); + // 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()); + } - if ($isset === false) { - return new SpecifiedTypes(); - } + $first = array_shift($issets); + $andChain = null; + foreach ($issets as $isset) { + if ($andChain === null) { + $andChain = new BooleanAnd($first, $isset); + continue; + } - $type = $scope->getType($issetExpr); - $isNullable = !$type->isNull()->no(); - $exprType = $typeSpecifier->create( - $issetExpr, - new NullType(), - $context->negate(), - $scope, - )->setRootExpr($expr); + $andChain = new BooleanAnd($andChain, $isset); + } - if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { - if ($isset === true) { - if ($isNullable) { - return $exprType; + if ($andChain === null) { + throw new ShouldNotHappenException(); } - // variable cannot exist in !isset() - return $exprType->unionWith($typeSpecifier->create( - new IssetExpr($issetExpr), - new NullType(), - $context, - $scope, - ))->setRootExpr($expr); + return $this->defaultNarrowingHelper->specifyTypesForNode($s, $andChain, $context)->setRootExpr($expr); } - if ($isNullable) { - // reduces variable certainty to maybe - return $exprType->unionWith($typeSpecifier->create( - new IssetExpr($issetExpr), - new NullType(), - $context->negate(), - $scope, - ))->setRootExpr($expr); - } + $issetExpr = $expr->vars[0]; - // variable cannot exist in !isset() - return $typeSpecifier->create( - new IssetExpr($issetExpr), - new NullType(), - $context, - $scope, - )->setRootExpr($expr); - } + if (!$context->true()) { + $isset = $varResults[0]->getIssetabilityResolution($s, false)->isSet(static fn (): bool => true); - if ($isNullable && $isset === true) { - return $exprType; - } + if ($isset === false) { + return new SpecifiedTypes(); + } - 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; + $type = $readType($issetExpr); + $isNullable = !$type->isNull()->no(); + $exprType = $this->defaultNarrowingHelper->createForSubject( + $issetExpr, + new NullType(), + $context->negate(), + $s, + )->setRootExpr($expr); + + if ($issetExpr instanceof Expr\Variable && is_string($issetExpr->name)) { + if ($isset === true) { + if ($isNullable) { + return $exprType; } - $typesToRemove[] = $constantArray; + // variable cannot exist in !isset() + return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + ))->setRootExpr($expr); } - 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; + if ($isNullable) { + // reduces variable certainty to maybe + return $exprType->unionWith($this->defaultNarrowingHelper->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context->negate(), + $s, + ))->setRootExpr($expr); } + + // variable cannot exist in !isset() + return $this->defaultNarrowingHelper->createForSubject( + new IssetExpr($issetExpr), + new NullType(), + $context, + $s, + )->setRootExpr($expr); } - } - } - return new SpecifiedTypes(); - } + if ($isNullable && $isset === true) { + return $exprType; + } - $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); + 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->defaultNarrowingHelper->createForSubject( + $issetExpr->var, + $typeToRemove, + TypeSpecifierContext::createFalse(), + $s, + )->setRootExpr($expr); + + if ($s->hasExpressionType($issetExpr->var)->maybe()) { + $result = $result->unionWith( + $this->defaultNarrowingHelper->createForSubject( + new IssetExpr($issetExpr->var), + new NullType(), + TypeSpecifierContext::createTruthy(), + $s, + )->setRootExpr($expr), + ); + } + + return $result; + } + } + } + } - $types = new SpecifiedTypes(); - foreach ($vars as $var) { + return new SpecifiedTypes(); + } - if ($var instanceof Expr\Variable && is_string($var->name)) { - if ($scope->hasVariableType($var->name)->no()) { - return (new SpecifiedTypes([], []))->setRootExpr($expr); + $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); - if ( - $var instanceof ArrayDimFetch - && $var->dim !== null - && !$scope->getType($var->var) instanceof MixedType - ) { - $dimType = $scope->getType($var->dim); + $types = new SpecifiedTypes(); + foreach ($vars as $var) { - 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); + if ($var instanceof Expr\Variable && is_string($var->name)) { + if ($s->hasVariableType($var->name)->no()) { + return (new SpecifiedTypes([], []))->setRootExpr($expr); + } + } - $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); - if ($narrowedKey !== null) { - $types = $types->unionWith( - $typeSpecifier->create( - $var->dim, - $narrowedKey, - $context, - $scope, - )->setRootExpr($expr), - ); + 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->defaultNarrowingHelper->createForSubject( + $var->var, + new HasOffsetType($dimType), + $context, + $s, + )->setRootExpr($expr), + ); + } else { + $varType = $readType($var->var); + + $narrowedKey = AllowedArrayKeysTypes::narrowOffsetKeyType($varType, $dimType); + if ($narrowedKey !== null) { + $types = $types->unionWith( + $this->defaultNarrowingHelper->createForSubject( + $var->dim, + $narrowedKey, + $context, + $s, + )->setRootExpr($expr), + ); + } + + if ($varType->isArray()->yes()) { + $types = $types->unionWith( + $this->defaultNarrowingHelper->createForSubject( + $var->var, + new NonEmptyArrayType(), + $context, + $s, + )->setRootExpr($expr), + ); + } + } } - if ($varType->isArray()->yes()) { + if ( + $var instanceof PropertyFetch + && $var->name instanceof Identifier + ) { $types = $types->unionWith( - $typeSpecifier->create( - $var->var, - new NonEmptyArrayType(), - $context, - $scope, - )->setRootExpr($expr), + $this->defaultNarrowingHelper->createForSubject($var->var, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $s)->setRootExpr($expr), + ); + } elseif ( + $var instanceof StaticPropertyFetch + && $var->class instanceof Expr + && $var->name instanceof VarLikeIdentifier + ) { + $types = $types->unionWith( + $this->defaultNarrowingHelper->createForSubject($var->class, new IntersectionType([ + new ObjectWithoutClassType(), + new HasPropertyType($var->name->toString()), + ]), TypeSpecifierContext::createTruthy(), $s)->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), - ); - } + $types = $types->unionWith( + $this->defaultNarrowingHelper->createForSubject($var, new NullType(), TypeSpecifierContext::createFalse(), $s)->setRootExpr($expr), + ); + } - return $types; + return $types; + }, + ); } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult + /** + * @param array $chainResults + */ + private function captureChainResults(Expr $node, ExpressionResultStorage $storage, array &$chainResults): void { - $hasYield = false; - $throwPoints = []; - $impurePoints = []; - $nonNullabilityResults = []; - $isAlwaysTerminating = false; - 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()); - $scope = $varResult->getScope(); - $hasYield = $hasYield || $varResult->hasYield(); - $throwPoints = array_merge($throwPoints, $varResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $varResult->getImpurePoints()); - $isAlwaysTerminating = $isAlwaysTerminating || $varResult->isAlwaysTerminating(); - $nonNullabilityResults[] = $nonNullabilityResult; - - if (!($var instanceof ArrayDimFetch)) { - continue; - } + $result = $storage->findExpressionResult($node); + if ($result !== null) { + $chainResults[spl_object_id($node)] = $result; + } - $varType = $scope->getType($var->var); - if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { - continue; + if ($node instanceof ArrayDimFetch) { + $this->captureChainResults($node->var, $storage, $chainResults); + if ($node->dim !== null) { + $this->captureChainResults($node->dim, $storage, $chainResults); } - - $throwPoints = array_merge($throwPoints, $nodeScopeResolver->processExprNode( - $stmt, - new MethodCall(new TypeExpr($varType), 'offsetExists'), - $scope, - $storage, - new NoopNodeCallback(), - $context, - )->getThrowPoints()); - } - foreach (array_reverse($expr->vars) as $var) { - $scope = $nodeScopeResolver->lookForUnsetAllowedUndefinedExpressions($scope, $var); + } elseif ($node instanceof PropertyFetch) { + $this->captureChainResults($node->var, $storage, $chainResults); + } elseif ($node instanceof StaticPropertyFetch && $node->class instanceof Expr) { + $this->captureChainResults($node->class, $storage, $chainResults); } - foreach (array_reverse($nonNullabilityResults) as $nonNullabilityResult) { - $scope = $this->nonNullabilityHelper->revertNonNullability($scope, $nonNullabilityResult->getSpecifiedExpressions()); - } - - return new ExpressionResult( - $scope, - hasYield: $hasYield, - isAlwaysTerminating: $isAlwaysTerminating, - throwPoints: $throwPoints, - impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); } } diff --git a/src/Analyser/ExprHandler/MatchHandler.php b/src/Analyser/ExprHandler/MatchHandler.php index 90d4714a5af..f0178e2db55 100644 --- a/src/Analyser/ExprHandler/MatchHandler.php +++ b/src/Analyser/ExprHandler/MatchHandler.php @@ -16,14 +16,14 @@ 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\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\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -57,6 +57,8 @@ final class MatchHandler implements ExprHandler public function __construct( #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -66,16 +68,6 @@ 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 @@ -85,10 +77,12 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type * * @return list */ - public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array + public function getArmScopesAndTypes(NodeScopeResolver $nodeScopeResolver, MutatingScope $scope, Match_ $expr): array { $cond = $expr->cond; - $condType = $scope->getType($cond); + // the subject was processed before this shadow walk runs; read its stored + // result on the incoming scope instead of re-walking via Scope::getType(). + $condType = $nodeScopeResolver->readStoredOrPriceOnDemand($cond, $scope); $armScopesAndTypes = []; $matchScope = $scope; @@ -150,7 +144,10 @@ public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array $cond, $conditionCaseType, ); - $armScopesAndTypes[] = [$armScope, $armScope->getType($arm->body)]; + // the arm body is read on the subject-narrowed scope this shadow + // walk built; that (body, narrowed-scope) pair is not stored, so + // price the body on demand against the current storage. + $armScopesAndTypes[] = [$armScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $armScope)]; unset($arms[$i]); } @@ -179,7 +176,7 @@ public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array 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)]; + $armScopesAndTypes[] = [$matchScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $matchScope)]; continue; } @@ -189,14 +186,16 @@ public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array $filteringExpr = $this->getFilteringExprForMatchArm($expr, $arm->conds); - $filteringExprType = $matchScope->getType($filteringExpr); + // the filtering expression is synthetic - price it on demand against the + // current storage instead of re-walking via Scope::getType(). + $filteringExprType = $nodeScopeResolver->priceSyntheticOnDemand($filteringExpr, $matchScope); 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)]; + $armScopesAndTypes[] = [$truthyScope, $nodeScopeResolver->priceSyntheticOnDemand($arm->body, $truthyScope)]; } $matchScope = $matchScope->filterByFalseyValue($filteringExpr); @@ -207,10 +206,13 @@ public function getArmScopesAndTypes(MutatingScope $scope, Match_ $expr): array public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $deepContext = $context->enterDeep(); - $condType = $scope->getType($expr->cond); - $condNativeType = $scope->getNativeType($expr->cond); $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->getTypeForScope($scope); + $condNativeType = $condResult->getNativeTypeForScope($scope); $scope = $condResult->getScope(); $hasYield = $condResult->hasYield(); $throwPoints = $condResult->getThrowPoints(); @@ -223,6 +225,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 @@ -364,6 +376,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]); } @@ -390,6 +403,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()); @@ -400,6 +414,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if (!$armResult->isAlwaysTerminating()) { $armBodyScopes[] = $matchScope; } + $armTypeResults[] = [$armResult, $defaultArmBodyScope, $arm->body]; continue; } @@ -423,7 +438,11 @@ 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` comparison is synthetic - price it on demand + // against the current storage instead of re-walking via Scope::getType(). + $armCondType = $this->treatPhpDocTypesAsCertain + ? $nodeScopeResolver->priceSyntheticOnDemand($armCondExpr, $armCondResultScope) + : $nodeScopeResolver->priceSyntheticOnDemand($armCondExpr, $armCondResultScope->doNotTreatPhpDocTypesAsCertain()); if ($armCondType->isTrue()->yes()) { $hasAlwaysTrueCond = true; } @@ -456,6 +475,14 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $hasYield = $hasYield || $armResult->hasYield(); $throwPoints = array_merge($throwPoints, $armResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $armResult->getImpurePoints()); + // Mirror getArmScopesAndTypes: an arm whose filtering expression is + // always false is unreachable and does not contribute to the result + // type. The filtering expression is synthetic - price it on demand + // against the current storage instead of re-walking via Scope::getType(). + $filteringExprType = $nodeScopeResolver->priceSyntheticOnDemand($filteringExpr, $matchScope); + if (!$filteringExprType->isFalse()->yes()) { + $armTypeResults[] = [$armResult, $bodyScope, $arm->body]; + } $matchScope = $armCondScope->filterByFalseyValue($filteringExpr); } @@ -470,7 +497,9 @@ 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" - a genuinely + // different scope than the subject's own - so reprocess the subject there. + $remainingType = $nodeScopeResolver->processExprOnDemand($expr->cond, $matchScope, new ExpressionResultStorage())->getType(); if ($remainingType instanceof NeverType) { $isExhaustive = true; } @@ -501,12 +530,38 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $expr->cond = $expr->cond->getExpr(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, 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 ($expr, $armTypeResults): Type { + $keepVoid = $expr->getAttribute(MutatingScope::KEEP_VOID_ATTRIBUTE_NAME) === true; + $types = []; + foreach ($armTypeResults as [$armResult, $bodyScope, $armBody]) { + if ($nativeTypesPromoted) { + $bodyScope = $bodyScope->doNotTreatPhpDocTypesAsCertain(); + } + if ($keepVoid) { + // The only caller is getKeepVoidType (via a synthetic + // clone of the match) - it keeps void in the arm bodies + // instead of transforming it to null. + $types[] = $bodyScope->getKeepVoidType($armBody); + } else { + $types[] = ($nativeTypesPromoted ? $armResult->getNativeType() : $armResult->getType()); + } + } + + return TypeCombinator::union(...$types); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } @@ -585,9 +640,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 1f661f5a980..a3d18fedf1d 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -11,16 +11,18 @@ 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; @@ -30,6 +32,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; @@ -60,6 +63,10 @@ public function __construct( private ReflectionProvider $reflectionProvider, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { } @@ -71,6 +78,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $originalScope = $scope; if ( ($expr->var instanceof Expr\Closure || $expr->var instanceof Expr\ArrowFunction) @@ -78,9 +86,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->getTypeForScope($scope), + $newThisResult->getNativeTypeForScope($scope), ); } @@ -94,24 +107,34 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->restoreOriginalScopeAfterClosureBind($originalScope); } $parametersAcceptor = null; + $variants = []; + $namedArgumentsVariants = null; $methodReflection = null; - $calledOnType = $scope->getType($expr->var); + $nameResult = null; + // the var was processed above as the receiver; read its already-computed + // result instead of re-walking via Scope::getType(). + $calledOnType = $varResult->getTypeForScope($scope); + // 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) { @@ -140,17 +163,90 @@ 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, + ); + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $s, + $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 = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $this->isMethodCallNarrowable($s, $expr, $varResult) + ? $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 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; + // the final result below overwrites it with the resolved scope and + // throw/impure points. + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + containsNullsafe: $varResult->containsNullsafe(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, + )); 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 and + // readStoredOrPriceOnDemand, 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. + $methodCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $varResult, $nameResult, $resolvedParametersAcceptor); + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $methodCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -159,24 +255,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->getNativeTypeForScope($scope), ); } } @@ -191,17 +293,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - $result = new ExpressionResult( + $result = $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + containsNullsafe: $varResult->containsNullsafe(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); - $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->getTypeForScope($originalScope); if (!$expr->name instanceof Identifier) { return $result; } @@ -219,14 +327,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $calledMethodScope = $nodeScopeResolver->processCalledMethod($methodReflection); if ($calledMethodScope !== null) { $scope = $scope->mergeInitializedProperties($calledMethodScope); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $result->hasYield(), isAlwaysTerminating: $result->isAlwaysTerminating(), throwPoints: $result->getThrowPoints(), impurePoints: $result->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + containsNullsafe: $varResult->containsNullsafe(), + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } } @@ -234,65 +346,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. + * + * @param MethodCall $expr + */ + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, MethodCall $expr, ExpressionResult $varResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type { - 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): 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, + ) ?? 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->readStoredOrPriceOnDemand($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->getTypeForScope($scope); $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 ( @@ -300,7 +451,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; } @@ -309,33 +460,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->typeSpecifier->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->typeSpecifier->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->getTypeForScope($scope); + $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 2360ccd8076..3945f823125 100644 --- a/src/Analyser/ExprHandler/NewHandler.php +++ b/src/Analyser/ExprHandler/NewHandler.php @@ -11,8 +11,10 @@ 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\ImpurePoint; use PHPStan\Analyser\InternalThrowPoint; use PHPStan\Analyser\MutatingScope; @@ -34,6 +36,7 @@ use PHPStan\Parser\NewAssignedToPropertyVisitor; 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; @@ -77,6 +80,9 @@ public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private bool $implicitThrows, + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -88,6 +94,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $parametersAcceptor = null; $constructorReflection = null; $classReflection = null; @@ -97,6 +104,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); @@ -111,12 +119,10 @@ 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; @@ -160,9 +166,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->getTypeForScope($scope)->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]; @@ -172,12 +193,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) { @@ -198,13 +213,56 @@ 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 (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $s, + $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 + // below overwrites it with the resolved scope and throw/impure points. + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + )); + if ($constructorReflection !== null && $parametersAcceptor !== null) { $className ??= $constructorReflection->getDeclaringClass()->getName(); $constructorThrowPoint = $this->getConstructorThrowPoint($constructorReflection, $parametersAcceptor, $expr, new Name\FullyQualified($className), $expr->getArgs(), $scope, $context); @@ -215,17 +273,21 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, ); } /** - * @return array{?MethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} + * @return array{?ExtendedMethodReflection, ?ClassReflection, ?ParametersAcceptor, ImpurePoint[]} */ private function processConstructorReflection(string $className, New_ $expr, MutatingScope $scope, bool $isDynamic): array { @@ -238,12 +300,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()); } } @@ -315,10 +375,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); @@ -326,11 +395,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->readStoredOrPriceOnDemand($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; @@ -376,8 +447,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(), @@ -407,6 +477,9 @@ private function exactInstantiation(MutatingScope $scope, New_ $node, Name $clas return TypeCombinator::union(...$resolvedTypes); } + // $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; @@ -612,13 +685,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()); @@ -627,17 +711,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->typeSpecifier->specifyTypesFromAsserts($context, $expr, $asserts, $resolvedParametersAcceptor, $scope); if ($specifiedTypes !== null) { return $specifiedTypes; @@ -645,6 +727,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 864a4859275..678e6125d6e 100644 --- a/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php +++ b/src/Analyser/ExprHandler/NullsafeMethodCallHandler.php @@ -12,16 +12,17 @@ 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\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; @@ -37,6 +38,8 @@ final class NullsafeMethodCallHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -46,58 +49,31 @@ 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->normalize($scope)->intersectWith($nullSafeTypes->normalize($scope)); - } - 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); - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + // the receiver's real (possibly null) type, captured before it is ensured + // non-null below: the short-circuit decision needs to know it can be null, + // which reading the ensured-non-null result would hide. + $receiverType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); + $receiverNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); + // 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($nodeScopeResolver, $scope, $scope, $expr->var); $attributes = array_merge($expr->getAttributes(), ['virtualNullsafeMethodCall' => true]); unset($attributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); + $methodCall = new MethodCall( + $expr->var, + $expr->name, + $expr->args, + $attributes, + ); $exprResult = $nodeScopeResolver->processExprNode( $stmt, - new MethodCall( - $expr->var, - $expr->name, - $expr->args, - $attributes, - ), + $methodCall, $nonNullabilityResult->getScope(), $storage, $nodeCallback, @@ -105,7 +81,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; @@ -115,14 +91,91 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $scope->mergeWith($scopeBeforeNullsafe); } - return new ExpressionResult( + // 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 ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { + if ($receiverType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($receiverType)) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } + + // the plain method call on the null-removed scope is synthetic; the + // null-removal narrowing is applied to beforeScope (the evaluation point), + // not the asking scope. + $truthyScope = $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())); + $methodCall = new MethodCall($expr->var, $expr->name, $expr->args); + + return TypeCombinator::union( + $nativeTypesPromoted + ? $nodeScopeResolver->priceSyntheticOnDemandNative($methodCall, $truthyScope) + : $nodeScopeResolver->priceSyntheticOnDemand($methodCall, $truthyScope), + new NullType(), + ); + }; + + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: false, throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + containsNullsafe: true, + typeCallback: $nullsafeTypeCallback, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $methodCall, $nodeScopeResolver): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + $types = $this->defaultNarrowingHelper->specifyTypesForNode( + $s, + new BooleanAnd( + new NotIdentical($expr->var, new ConstFetch(new Name('null'))), + $methodCall, + ), + $context, + )->setRootExpr($expr); + + $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); + }, + // 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 (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $methodCall, $exprResult, $nullsafeTypeCallback): 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); + } + + $nullsafeType = $nullsafeTypeCallback($s->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". + 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, null, new NullType(), TypeSpecifierContext::createFalse())) + ->setRootExpr($expr); + }, ); } diff --git a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php index 0c9e190ff47..dfb01d2d559 100644 --- a/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/NullsafePropertyFetchHandler.php @@ -12,16 +12,17 @@ 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\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; @@ -37,6 +38,8 @@ final class NullsafePropertyFetchHandler implements ExprHandler public function __construct( private NonNullabilityHelper $nonNullabilityHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -46,62 +49,113 @@ 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->normalize($scope)->intersectWith($nullSafeTypes->normalize($scope)); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - $nonNullabilityResult = $this->nonNullabilityHelper->ensureShallowNonNullability($scope, $scope, $expr->var); + $beforeScope = $scope; + // the receiver's real (possibly null) type, captured before it is ensured + // non-null below: the short-circuit decision needs to know it can be null, + // which reading the ensured-non-null result would hide. + $receiverType = $nodeScopeResolver->readStoredOrPriceOnDemand($expr->var, $scope); + $receiverNativeType = $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->var, $scope); + // 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($nodeScopeResolver, $scope, $scope, $expr->var); $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->processExprNode($stmt, $propertyFetch, $nonNullabilityResult->getScope(), $storage, $nodeCallback, $context); $scope = $this->nonNullabilityHelper->revertNonNullability($exprResult->getScope(), $nonNullabilityResult->getSpecifiedExpressions()); - return new ExpressionResult( + // 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 ($expr, $exprResult, $nodeScopeResolver, $receiverType, $beforeScope): Type { + if ($receiverType->isNull()->yes()) { + return new NullType(); + } + if (!TypeCombinator::containsNull($receiverType)) { + return $nativeTypesPromoted ? $exprResult->getNativeType() : $exprResult->getType(); + } + + // the plain property fetch on the null-removed scope is synthetic; the + // null-removal narrowing is applied to beforeScope (the evaluation point), + // not the asking scope. + $truthyScope = $beforeScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new NotIdentical($expr->var, new ConstFetch(new Name('null'))), $beforeScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($beforeScope, TypeSpecifierContext::createTruthy())); + $propertyFetch = new PropertyFetch($expr->var, $expr->name); + + return TypeCombinator::union( + $nativeTypesPromoted + ? $nodeScopeResolver->priceSyntheticOnDemandNative($propertyFetch, $truthyScope) + : $nodeScopeResolver->priceSyntheticOnDemand($propertyFetch, $truthyScope), + new NullType(), + ); + }; + + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), isAlwaysTerminating: false, throwPoints: $exprResult->getThrowPoints(), impurePoints: $exprResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + containsNullsafe: true, + typeCallback: $nullsafeTypeCallback, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr, $propertyFetch, $nodeScopeResolver): SpecifiedTypes { + if ($context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + } + + $types = $this->defaultNarrowingHelper->specifyTypesForNode( + $s, + new BooleanAnd( + new NotIdentical($expr->var, new ConstFetch(new Name('null'))), + $propertyFetch, + ), + $context, + )->setRootExpr($expr); + + $nullSafeTypes = $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); + return $context->true() ? $types->unionWith($nullSafeTypes) : $types->normalize($s, $nodeScopeResolver)->intersectWith($nullSafeTypes->normalize($s, $nodeScopeResolver)); + }, + // 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 (MutatingScope $s, Type $type, TypeSpecifierContext $context) use ($expr, $propertyFetch, $exprResult, $nullsafeTypeCallback): 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); + } + + $nullsafeType = $nullsafeTypeCallback($s->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". + 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, null, new NullType(), TypeSpecifierContext::createFalse())) + ->setRootExpr($expr); + }, ); } diff --git a/src/Analyser/ExprHandler/PipeHandler.php b/src/Analyser/ExprHandler/PipeHandler.php index 93bd3554ef7..05390a63c03 100644 --- a/src/Analyser/ExprHandler/PipeHandler.php +++ b/src/Analyser/ExprHandler/PipeHandler.php @@ -11,16 +11,19 @@ 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\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; @@ -32,30 +35,16 @@ final class PipeHandler implements ExprHandler { - public function supports(Expr $expr): bool + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { - return $expr instanceof Pipe; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function supports(Expr $expr): bool { - 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), - ])); + return $expr instanceof Pipe; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult @@ -64,38 +53,61 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex unset($rightAttributes[ExprPrinter::ATTRIBUTE_CACHE_KEY]); $argAttributes = $expr->getAttribute(ReversePipeTransformerVisitor::ARG_ATTRIBUTES_NAME, []); + $firstClassCallableNode = null; if ($expr->right instanceof FuncCall && $expr->right->isFirstClassCallable()) { $callExpr = new FuncCall($expr->right->name, [ new Arg($expr->left, attributes: $argAttributes), ], $rightAttributes); + $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); + $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); + $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 ($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, + expr: $expr->right, + hasYield: false, + isAlwaysTerminating: false, + throwPoints: [], + impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $callableNodeResult->getNativeType() : $callableNodeResult->getType()), + specifyTypesCallback: static fn () => new SpecifiedTypes(), + )); + } + $callResult = $nodeScopeResolver->processExprNode($stmt, $callExpr, $scope, $storage, $nodeCallback, $context); - return new ExpressionResult( + return $this->expressionResultFactory->create( $callResult->getScope(), + beforeScope: $scope, + expr: $expr, hasYield: $callResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context): 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 6c38de4a62e..b466ea9258d 100644 --- a/src/Analyser/ExprHandler/PostDecHandler.php +++ b/src/Analyser/ExprHandler/PostDecHandler.php @@ -8,13 +8,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\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; @@ -26,6 +26,13 @@ final class PostDecHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PostDec; @@ -35,32 +42,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $nodeScopeResolver->processVirtualAssign( - $varResult->getScope(), - $storage, - $stmt, - $expr->var, - new PreDec($expr->var), - $nodeCallback, - )->getScope(); - - return new ExpressionResult( - $scope, + return $this->expressionResultFactory->create( + $nodeScopeResolver->processVirtualAssign( + $varResult->getScope(), + $storage, + $stmt, + $expr->var, + new PreDec($expr->var), + $nodeCallback, + )->getScope(), + beforeScope: $scope, + expr: $expr, hasYield: $varResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context): 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 f26c45c50fe..94c587b1e10 100644 --- a/src/Analyser/ExprHandler/PostIncHandler.php +++ b/src/Analyser/ExprHandler/PostIncHandler.php @@ -8,13 +8,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\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; @@ -26,6 +26,13 @@ final class PostIncHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PostInc; @@ -35,32 +42,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); - $scope = $nodeScopeResolver->processVirtualAssign( - $varResult->getScope(), - $storage, - $stmt, - $expr->var, - new PreInc($expr->var), - $nodeCallback, - )->getScope(); - - return new ExpressionResult( - $scope, + return $this->expressionResultFactory->create( + $nodeScopeResolver->processVirtualAssign( + $varResult->getScope(), + $storage, + $stmt, + $expr->var, + new PreInc($expr->var), + $nodeCallback, + )->getScope(), + beforeScope: $scope, + expr: $expr, hasYield: $varResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context): 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 4abde925a80..7c7d4d27271 100644 --- a/src/Analyser/ExprHandler/PreDecHandler.php +++ b/src/Analyser/ExprHandler/PreDecHandler.php @@ -3,23 +3,26 @@ 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\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\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -40,85 +43,115 @@ final class PreDecHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PreDec; } - 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 { - $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(); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $varResult): Type { + $varType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + $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; } - } 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(), + ]); } - $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(), + ]); + } - if ($varType->isNumericString()->yes()) { return new BenevolentUnionType([ + new StringType(), new IntegerType(), new FloatType(), ]); } - return new BenevolentUnionType([ - new StringType(), - new IntegerType(), - new FloatType(), - ]); - } - - return $scope->getType(new Minus($expr->var, new Int_(1))); - } + $one = new Int_(1); + return $this->initializerExprTypeResolver->getMinusType($expr->var, $one, static function (Expr $e) use ($nativeTypesPromoted, $expr, $varResult, $one): Type { + if ($e === $expr->var) { + return ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + } + if ($e === $one) { + return new ConstantIntegerType(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()); + throw new ShouldNotHappenException(); + }); + }; + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); - $scope = $nodeScopeResolver->processVirtualAssign( + // processVirtualAssign asks getType($expr) for the value to assign; store + // this result first so that resolves from the typeCallback below rather + // than re-processing the node on demand (which would recurse). + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( $varResult->getScope(), - $storage, - $stmt, - $expr->var, - $expr, - $nodeCallback, - )->getScope(); - - return new ExpressionResult( - $scope, + 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(), + $storage, + $stmt, + $expr->var, + $expr, + $nodeCallback, + )->getScope(), + beforeScope: $scope, + expr: $expr, + hasYield: $varResult->hasYield(), + 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 1750b876542..b39f30bc872 100644 --- a/src/Analyser/ExprHandler/PreIncHandler.php +++ b/src/Analyser/ExprHandler/PreIncHandler.php @@ -3,23 +3,26 @@ 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\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\Accessory\AccessoryLiteralStringType; use PHPStan\Type\BenevolentUnionType; +use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\ConstantTypeHelper; use PHPStan\Type\FloatType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; @@ -41,85 +44,115 @@ final class PreIncHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private InitializerExprTypeResolver $initializerExprTypeResolver, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof PreInc; } - 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 { - $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(); + $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); + + $typeCallback = function (bool $nativeTypesPromoted) use ($expr, $varResult): Type { + $varType = ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + $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; } - } elseif (!is_bool($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(), + ]); } - $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(), + ]); + } - if ($varType->isNumericString()->yes()) { return new BenevolentUnionType([ + new StringType(), new IntegerType(), new FloatType(), ]); } - return new BenevolentUnionType([ - new StringType(), - new IntegerType(), - new FloatType(), - ]); - } - - return $scope->getType(new Plus($expr->var, new Int_(1))); - } + $one = new Int_(1); + return $this->initializerExprTypeResolver->getPlusType($expr->var, $one, static function (Expr $e) use ($nativeTypesPromoted, $expr, $varResult, $one): Type { + if ($e === $expr->var) { + return ($nativeTypesPromoted ? $varResult->getNativeType() : $varResult->getType()); + } + if ($e === $one) { + return new ConstantIntegerType(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()); + throw new ShouldNotHappenException(); + }); + }; + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context); - $scope = $nodeScopeResolver->processVirtualAssign( + // processVirtualAssign asks getType($expr) for the value to assign; store + // this result first so that resolves from the typeCallback below rather + // than re-processing the node on demand (which would recurse). + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( $varResult->getScope(), - $storage, - $stmt, - $expr->var, - $expr, - $nodeCallback, - )->getScope(); - - return new ExpressionResult( - $scope, + 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(), + $storage, + $stmt, + $expr->var, + $expr, + $nodeCallback, + )->getScope(), + beforeScope: $scope, + expr: $expr, + hasYield: $varResult->hasYield(), + 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 9d8b220ccfe..30a81985eb4 100644 --- a/src/Analyser/ExprHandler/PrintHandler.php +++ b/src/Analyser/ExprHandler/PrintHandler.php @@ -7,15 +7,14 @@ 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\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; @@ -31,6 +30,8 @@ final class PrintHandler implements ExprHandler public function __construct( private ImplicitToStringCallHelper $implicitToStringCallHelper, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -40,35 +41,30 @@ 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; $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); $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()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $exprResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context) => $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 9be0c5c28be..579b1acc30b 100644 --- a/src/Analyser/ExprHandler/PropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/PropertyFetchHandler.php @@ -5,22 +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; @@ -40,6 +40,8 @@ final class PropertyFetchHandler implements ExprHandler public function __construct( private PhpVersion $phpVersion, private PropertyReflectionFinder $propertyReflectionFinder, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,6 +53,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $scopeBeforeVar = $scope; $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->var, $scope, $storage, $nodeCallback, $context->enterDeep()); $hasYield = $varResult->hasYield(); @@ -58,9 +61,10 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = $varResult->getImpurePoints(); $isAlwaysTerminating = $varResult->isAlwaysTerminating(); $scope = $varResult->getScope(); + $nameResult = null; if ($expr->name instanceof Identifier) { $propertyName = $expr->name->toString(); - $propertyHolderType = $scopeBeforeVar->getType($expr->var); + $propertyHolderType = $varResult->getTypeForScope($scopeBeforeVar); $propertyReflection = $scopeBeforeVar->getInstancePropertyReflection($propertyHolderType, $propertyName); if ($propertyReflection !== null && $this->phpVersion->supportsPropertyHooks()) { $propertyDeclaringClass = $propertyReflection->getDeclaringClass(); @@ -81,60 +85,72 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } - - 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(); + 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(); + } + + return $this->propertyFetchType($reflectionScope, $receiverType, $propertyName, $expr) ?? new ErrorType(); + }; + + if ($expr->name instanceof Identifier) { + return $shortCircuit($resolveProperty($expr->name->toString())); } - if (!$propertyReflection->hasNativeType()) { - return new MixedType(); + // 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->readStoredOrPriceOnDemand($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()), + ); } - $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()), - ); - } - - return new MixedType(); + return new MixedType(); + }, + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, PropertyFetch $propertyFetch): ?Type @@ -151,9 +167,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 abd0dc63a4f..a27e0f04ec4 100644 --- a/src/Analyser/ExprHandler/ScalarHandler.php +++ b/src/Analyser/ExprHandler/ScalarHandler.php @@ -2,24 +2,21 @@ namespace PHPStan\Analyser\ExprHandler; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; use PhpParser\Node\Scalar; use PhpParser\Node\Scalar\InterpolatedString; 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\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 @@ -30,6 +27,7 @@ final class ScalarHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, ) { } @@ -41,23 +39,21 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - return new 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, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: fn () => $this->initializerExprTypeResolver->getType($expr, $initializerExprContext), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } - 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..7c9f7ce674d --- /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 (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + +} diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 872fac5c790..b1e4bed4bde 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -14,17 +14,18 @@ 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; @@ -34,6 +35,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; @@ -43,15 +45,12 @@ use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\ObjectType; -use PHPStan\Type\StaticType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; -use PHPStan\Type\TypeWithClassName; use ReflectionProperty; use function array_map; use function array_merge; use function count; -use function in_array; use function sprintf; use function strtolower; @@ -68,6 +67,10 @@ public function __construct( private ReflectionProvider $reflectionProvider, #[AutowiredParameter] private bool $rememberPossiblyImpureFunctionValues, + private ExpressionResultFactory $expressionResultFactory, + private TypeSpecifier $typeSpecifier, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private EarlyTerminatingCallHelper $earlyTerminatingHelper, ) { } @@ -79,10 +82,14 @@ 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; $hasYield = false; $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(); @@ -91,80 +98,96 @@ 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->getTypeForScope($scope); + $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); $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; - } - - $nativeArgType = $scope->getNativeType($expr->getArgs()[1]->value); - if ($nativeArgType->isNull()->yes()) { - $nativeThisType = null; - } else { - $nativeThisType = $nativeArgType; + $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 = $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(). + $classType = $classResult->getTypeForScope($scope)->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 { @@ -176,7 +199,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->getTypeForScope($scope)->getObjectClassNames(); if (count($objectClasses) !== 1) { $objectClasses = $scope->getType(new New_($expr->class))->getObjectClassNames(); } @@ -212,12 +237,84 @@ 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, + ); + $specifyTypesCallback = fn (MutatingScope $s, TypeSpecifierContext $specifyContext): SpecifiedTypes => $this->specifyTypes( + $nodeScopeResolver, + $s, + $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 = fn (MutatingScope $s, Type $type, TypeSpecifierContext $createContext): SpecifiedTypes => $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; the final result below overwrites it with the + // resolved scope and throw/impure points. + $nodeScopeResolver->storeExpressionResult($storage, $expr, $this->expressionResultFactory->create( + $scope, + beforeScope: $beforeScope, + expr: $expr, + hasYield: $hasYield, + isAlwaysTerminating: $isAlwaysTerminating, + throwPoints: [], + impurePoints: [], + containsNullsafe: $containsNullsafe, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, + )); + 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 and + // readStoredOrPriceOnDemand, 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. + $staticCallReturnType = $this->resolveReturnType($nodeScopeResolver, $scope, false, $expr, $classResult, $nameResult, $resolvedParametersAcceptor); + $methodThrowPoint = $this->methodThrowPointHelper->getThrowPoint($methodReflection, $parametersAcceptor, $normalizedExpr, $scope, $context, $staticCallReturnType); if ($methodThrowPoint !== null) { $throwPoints[] = $methodThrowPoint; } @@ -246,9 +343,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(), ); } @@ -279,121 +380,144 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $argsResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $argsResult->isAlwaysTerminating(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), + containsNullsafe: $containsNullsafe, + typeCallback: $typeCallback, + specifyTypesCallback: $specifyTypesCallback, + createTypesCallback: $createTypesCallback, ); } - 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. + * + * @param StaticCall $expr + */ + private function resolveReturnType(NodeScopeResolver $nodeScopeResolver, MutatingScope $reflectionScope, bool $nativeTypesPromoted, StaticCall $expr, ?ExpressionResult $classResult, ?ExpressionResult $nameResult, ?ParametersAcceptor $preResolvedAcceptor): Type { - 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): Type { + if ($nativeTypesPromoted) { if ($expr->class instanceof Name) { - $staticMethodCalledOnType = $this->resolveTypeByNameWithLateStaticBinding($scope, $expr->class, $expr->name); + $staticMethodCalledOnType = $reflectionScope->resolveTypeByName($expr->class); } else { - $staticMethodCalledOnType = $scope->getNativeType($expr->class); + $staticMethodCalledOnType = $classType ?? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->class, $reflectionScope); } - $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 = $reflectionScope->resolveTypeByName($expr->class); } else { - $staticMethodCalledOnType = TypeCombinator::removeNull($scope->getType($expr->class))->getObjectTypeOrClassStringObjectType(); + $resolvedClassType = $classType ?? $nodeScopeResolver->readStoredOrPriceOnDemand($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, + ) ?? 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->readStoredOrPriceOnDemand($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->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($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 ( @@ -401,7 +525,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; } @@ -410,33 +534,78 @@ 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->typeSpecifier->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->typeSpecifier->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->getTypeForScope($scope) + : $nodeScopeResolver->readStoredOrPriceOnDemand($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(); } } diff --git a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php index ec36cf64388..53d70b3e2b0 100644 --- a/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php +++ b/src/Analyser/ExprHandler/StaticPropertyFetchHandler.php @@ -11,17 +11,18 @@ 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\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; @@ -40,6 +41,8 @@ final class StaticPropertyFetchHandler implements ExprHandler public function __construct( private PropertyReflectionFinder $propertyReflectionFinder, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -51,6 +54,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $hasYield = false; $throwPoints = []; $impurePoints = [ @@ -63,6 +67,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(); @@ -71,6 +76,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(); @@ -80,71 +86,78 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $scope = $nameResult->getScope(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - if ($expr->name instanceof VarLikeIdentifier) { - if ($scope->nativeTypesPromoted) { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr, $scope); - if ($propertyReflection === null) { - return new ErrorType(); - } - if (!$propertyReflection->hasNativeType()) { - return new MixedType(); + 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->readStoredOrPriceOnDemand($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->readStoredOrPriceOnDemand($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 (MutatingScope $s, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } private function propertyFetchType(MutatingScope $scope, Type $fetchedOnType, string $propertyName, StaticPropertyFetch $propertyFetch): ?Type @@ -161,9 +174,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 3122a02e23c..3376b2bc88b 100644 --- a/src/Analyser/ExprHandler/TernaryHandler.php +++ b/src/Analyser/ExprHandler/TernaryHandler.php @@ -9,14 +9,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\DefaultNarrowingHelper; 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\Type\NeverType; @@ -32,7 +31,8 @@ final class TernaryHandler implements ExprHandler { public function __construct( - private NodeScopeResolver $nodeScopeResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -42,62 +42,6 @@ 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 - { - 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); - } - public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { $ternaryCondResult = $nodeScopeResolver->processExprNode($stmt, $expr->cond, $scope, $storage, $nodeCallback, $context->enterDeep()); @@ -107,7 +51,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()); @@ -120,7 +67,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $impurePoints = array_merge($impurePoints, $ifResult->getImpurePoints()); $hasYield = $hasYield || $ifResult->hasYield(); $ifTrueScope = $ifResult->getScope(); - $ifTrueType = $ifTrueScope->getType($expr->if); + $ifTrueType = $ifResult->getTypeForScope($ifTrueScope); $elseResult = $nodeScopeResolver->processExprNode($stmt, $expr->else, $ifFalseScope, $storage, $nodeCallback, $context); $throwPoints = array_merge($throwPoints, $elseResult->getThrowPoints()); @@ -129,7 +76,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $ifFalseScope = $elseResult->getScope(); } - $condType = $scope->getType($expr->cond); + $condType = $ternaryCondResult->getTypeForScope($scope); if ($condType->isTrue()->yes()) { $finalScope = $ifTrueScope; } elseif ($condType->isFalse()->yes()) { @@ -138,7 +85,7 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($ifTrueType instanceof NeverType && $ifTrueType->isExplicit()) { $finalScope = $ifFalseScope; } else { - $ifFalseType = $ifFalseScope->getType($expr->else); + $ifFalseType = $elseResult->getTypeForScope($ifFalseScope); if ($ifFalseType instanceof NeverType && $ifFalseType->isExplicit()) { $finalScope = $ifTrueScope; @@ -148,14 +95,76 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex } } - return new ExpressionResult( + return $this->expressionResultFactory->create( $finalScope, + beforeScope: $scope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $ternaryCondResult->isAlwaysTerminating(), throwPoints: $throwPoints, impurePoints: $impurePoints, - truthyScopeCallback: static fn (): MutatingScope => $finalScope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $finalScope->filterByFalseyValue($expr), + // 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 + typeCallback: static function (bool $nativeTypesPromoted) use ($expr, $ternaryCondResult, $ifResult, $elseResult, $ifProcessingScope, $nodeScopeResolver): Type { + if ($nativeTypesPromoted) { + $ifProcessingScope = $ifProcessingScope->doNotTreatPhpDocTypesAsCertain(); + } + $booleanConditionType = ($nativeTypesPromoted ? $ternaryCondResult->getNativeType() : $ternaryCondResult->getType())->toBoolean(); + $elseType = ($nativeTypesPromoted ? $elseResult->getNativeType() : $elseResult->getType()); + if ($expr->if === null || $ifResult === null) { + // short-ternary truthy value: the condition read on its own truthy scope + // is a different scope than its own, so reprocess it there. + $condTruthyType = $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 = ($nativeTypesPromoted ? $ifResult->getNativeType() : $ifResult->getType()); + if ($booleanConditionType->isTrue()->yes()) { + return $ifType; + } + + if ($booleanConditionType->isFalse()->yes()) { + return $elseType; + } + + return TypeCombinator::union( + $ifType, + $elseType, + ); + }, + specifyTypesCallback: function (MutatingScope $s, TypeSpecifierContext $context) use ($expr): SpecifiedTypes { + if ($expr->cond instanceof Ternary || $context->null()) { + return $this->defaultNarrowingHelper->specifyDefaultTypes($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), + ); + } + + // the synthetic condition takes the on-demand bridge; its real + // subnodes answer from stored results + return $s->obtainResultForNode($conditionExpr)->getSpecifiedTypesForScope($s, $context)->setRootExpr($expr); + }, ); } diff --git a/src/Analyser/ExprHandler/ThrowHandler.php b/src/Analyser/ExprHandler/ThrowHandler.php index 63c9b4720e8..af65c3b2a00 100644 --- a/src/Analyser/ExprHandler/ThrowHandler.php +++ b/src/Analyser/ExprHandler/ThrowHandler.php @@ -7,14 +7,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\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; @@ -28,6 +27,13 @@ final class ThrowHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof Throw_; @@ -37,23 +43,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()->enterThrow()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: true, - throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $scope->getType($expr->expr), $expr, false)]), + throwPoints: array_merge($exprResult->getThrowPoints(), [InternalThrowPoint::createExplicit($scope, $exprResult->getTypeForScope($scope), $expr, false)]), impurePoints: $exprResult->getImpurePoints(), + typeCallback: static fn (bool $nativeTypesPromoted): Type => new NonAcceptingNeverType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $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 2bc2d872380..72542829fe3 100644 --- a/src/Analyser/ExprHandler/UnaryMinusHandler.php +++ b/src/Analyser/ExprHandler/UnaryMinusHandler.php @@ -7,13 +7,12 @@ 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\Reflection\InitializerExprTypeResolver; @@ -28,6 +27,8 @@ final class UnaryMinusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,23 +42,25 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), 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 $nativeTypesPromoted ? $nodeScopeResolver->priceSyntheticOnDemandNative($e, $scope) : $nodeScopeResolver->priceSyntheticOnDemand($e, $scope); + }), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/UnaryPlusHandler.php b/src/Analyser/ExprHandler/UnaryPlusHandler.php index 676110b904f..a4f2bb4f1c0 100644 --- a/src/Analyser/ExprHandler/UnaryPlusHandler.php +++ b/src/Analyser/ExprHandler/UnaryPlusHandler.php @@ -7,16 +7,16 @@ 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\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Type; /** @@ -28,6 +28,8 @@ final class UnaryPlusHandler implements ExprHandler public function __construct( private InitializerExprTypeResolver $initializerExprTypeResolver, + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, ) { } @@ -41,23 +43,23 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex { $exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep()); - return new ExpressionResult( + return $this->expressionResultFactory->create( $exprResult->getScope(), + beforeScope: $scope, + expr: $expr, hasYield: $exprResult->hasYield(), 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 (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/VariableHandler.php b/src/Analyser/ExprHandler/VariableHandler.php index e104b9fed42..3d5f1a1590d 100644 --- a/src/Analyser/ExprHandler/VariableHandler.php +++ b/src/Analyser/ExprHandler/VariableHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\ExprHandler; +use Closure; use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\Identical; use PhpParser\Node\Expr\Variable; @@ -9,16 +10,19 @@ 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\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\ShouldNotHappenException; use PHPStan\Type\ErrorType; use PHPStan\Type\MixedType; use PHPStan\Type\Type; @@ -34,49 +38,71 @@ final class VariableHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + 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. Also used by + * AssignHandler for the placeholder result it stores for an assignment + * target - every stored result for a Variable node must carry a + * typeCallback so it can resolve its own type from the stored result. + * + * @return Closure(bool $nativeTypesPromoted): Type + */ + public static function createTypeCallback(Variable $expr, NodeScopeResolver $nodeScopeResolver, MutatingScope $beforeScope, ?ExpressionResult $nameResult = null): Closure { - if (is_string($expr->name)) { - if ($scope->hasVariableType($expr->name)->no()) { - return new ErrorType(); + return static function (bool $nativeTypesPromoted) use ($expr, $nameResult, $nodeScopeResolver, $beforeScope): Type { + $readScope = $nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope; + if (is_string($expr->name)) { + if ($readScope->hasVariableType($expr->name)->no()) { + return new ErrorType(); + } + + return $readScope->getVariableType($expr->name); } - return $scope->getVariableType($expr->name); - } + // 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) { + $variableScope = $readScope->applySpecifiedTypes($nodeScopeResolver->processExprOnDemand(new Identical($expr->name, new String_($constantString->getValue())), $readScope, new ExpressionResultStorage())->getSpecifiedTypesForScope($readScope, TypeSpecifierContext::createTruthy())); + if ($variableScope->hasVariableType($constantString->getValue())->no()) { + $types[] = new ErrorType(); + continue; + } - $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())), - ); - 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 { + $beforeScope = $scope; $hasYield = false; $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); @@ -89,20 +115,19 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); $scope = $nameResult->getScope(); } - return new ExpressionResult( + + return $this->expressionResultFactory->create( $scope, - $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: self::createTypeCallback($expr, $nodeScopeResolver, $beforeScope, $nameResult), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context): 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 fcf77547e33..4241ff8a3d8 100644 --- a/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/AlwaysRememberedExprHandler.php @@ -6,13 +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\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; @@ -25,6 +25,13 @@ final class AlwaysRememberedExprHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof AlwaysRememberedExpr; @@ -40,29 +47,30 @@ public function processExpr( ExpressionContext $context, ): ExpressionResult { + $beforeScope = $scope; $innerExpr = $expr->getExpr(); $innerResult = $nodeScopeResolver->processExprNode($stmt, $innerExpr, $scope, $storage, $nodeCallback, $context); $scope = $innerResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $innerResult->hasYield(), isAlwaysTerminating: $innerResult->isAlwaysTerminating(), throwPoints: $innerResult->getThrowPoints(), impurePoints: $innerResult->getImpurePoints(), - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($innerExpr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($innerExpr), + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeExprType() : $expr->getExprType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + // 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: fn (MutatingScope $s, Type $type, TypeSpecifierContext $context): SpecifiedTypes => $this->defaultNarrowingHelper->createSubjectTypes($s, $expr, null, $type, $context)->unionWith( + $this->defaultNarrowingHelper->createSubjectTypes($s, $innerExpr, $innerResult, $type, $context), + ), ); } - 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); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php index c2bf0d37fab..59768ebeecc 100644 --- a/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php +++ b/src/Analyser/ExprHandler/Virtual/ExistingArrayDimFetchHandler.php @@ -2,18 +2,16 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; 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\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; @@ -25,6 +23,10 @@ final class ExistingArrayDimFetchHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof ExistingArrayDimFetch; @@ -32,26 +34,25 @@ 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 new ExpressionResult( + // 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, + beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => ($nativeTypesPromoted ? $arrayDimFetchResult->getNativeType() : $arrayDimFetchResult->getType()), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } - 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 b358f70c8df..61f41a65e84 100644 --- a/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/FunctionCallableNodeHandler.php @@ -2,21 +2,24 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use Closure; use PhpParser\Node\Expr; 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\FunctionCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\ObjectType; use PHPStan\Type\Type; /** @@ -26,6 +29,14 @@ final class FunctionCallableNodeHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof FunctionCallableNode; @@ -33,10 +44,12 @@ 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; $throwPoints = []; $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(); @@ -46,25 +59,41 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->resolveType($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $nameResult), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $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->getTypeForScope($scope); + 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/GetIterableKeyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php deleted file mode 100644 index a9de984485e..00000000000 --- a/src/Analyser/ExprHandler/Virtual/GetIterableKeyTypeExprHandler.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ -#[AutowiredService] -final class GetIterableKeyTypeExprHandler implements ExprHandler -{ - - public function supports(Expr $expr): bool - { - return $expr instanceof GetIterableKeyTypeExpr; - } - - 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 new ExpressionResult( - $scope, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getIterableKeyType($scope->getType($expr->getExpr())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - -} diff --git a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php deleted file mode 100644 index 261c364ffd3..00000000000 --- a/src/Analyser/ExprHandler/Virtual/GetIterableValueTypeExprHandler.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ -#[AutowiredService] -final class GetIterableValueTypeExprHandler implements ExprHandler -{ - - public function supports(Expr $expr): bool - { - return $expr instanceof GetIterableValueTypeExpr; - } - - 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 new ExpressionResult( - $scope, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getIterableValueType($scope->getType($expr->getExpr())); - } - - public function specifyTypes(TypeSpecifier $typeSpecifier, Scope $scope, Expr $expr, TypeSpecifierContext $context): SpecifiedTypes - { - return $typeSpecifier->specifyDefaultTypes($scope, $expr, $context); - } - -} diff --git a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php deleted file mode 100644 index 09922c7daa7..00000000000 --- a/src/Analyser/ExprHandler/Virtual/GetOffsetValueTypeExprHandler.php +++ /dev/null @@ -1,57 +0,0 @@ - - */ -#[AutowiredService] -final class GetOffsetValueTypeExprHandler implements ExprHandler -{ - - public function supports(Expr $expr): bool - { - return $expr instanceof GetOffsetValueTypeExpr; - } - - 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 new ExpressionResult( - $scope, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - return $scope->getType($expr->getVar())->getOffsetValueType($scope->getType($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/InstantiationCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php index eb552e3a544..2fed5a5cce8 100644 --- a/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/InstantiationCallableNodeHandler.php @@ -6,17 +6,17 @@ 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\InstantiationCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; /** @@ -26,6 +26,14 @@ final class InstantiationCallableNodeHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof InstantiationCallableNode; @@ -33,6 +41,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $throwPoints = []; $impurePoints = []; $hasYield = false; @@ -46,25 +55,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $classResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($beforeScope), $nativeTypesPromoted), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $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..f39d92deab9 --- /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 => $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->getExpr(), $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->getExpr(), $scope), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + +} diff --git a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php index f2d224bc91a..d75d02529a5 100644 --- a/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/MethodCallableNodeHandler.php @@ -2,21 +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; @@ -27,6 +29,14 @@ final class MethodCallableNodeHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof MethodCallableNode; @@ -34,6 +44,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $varResult = $nodeScopeResolver->processExprNode($stmt, $expr->getVar(), $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); $scope = $varResult->getScope(); $hasYield = $varResult->hasYield(); @@ -49,25 +60,39 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $nameResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->resolveType($nativeTypesPromoted ? $beforeScope->doNotTreatPhpDocTypesAsCertain() : $beforeScope, $expr, $varResult), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $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->getTypeForScope($scope); + $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 c952fcc1297..10e30c6061f 100644 --- a/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/NativeTypeExprHandler.php @@ -6,13 +6,12 @@ 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\Expr\NativeTypeExpr; @@ -25,6 +24,13 @@ final class NativeTypeExprHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof NativeTypeExpr; @@ -35,26 +41,17 @@ 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 new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => $nativeTypesPromoted ? $expr->getNativeType() : $expr->getPhpDocType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - 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); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php deleted file mode 100644 index 7893990b1a3..00000000000 --- a/src/Analyser/ExprHandler/Virtual/OriginalPropertyTypeExprHandler.php +++ /dev/null @@ -1,70 +0,0 @@ - - */ -#[AutowiredService] -final class OriginalPropertyTypeExprHandler implements ExprHandler -{ - - public function __construct( - private PropertyReflectionFinder $propertyReflectionFinder, - ) - { - } - - public function supports(Expr $expr): bool - { - return $expr instanceof OriginalPropertyTypeExpr; - } - - 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 new ExpressionResult( - $scope, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - ); - } - - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $propertyReflection = $this->propertyReflectionFinder->findPropertyReflectionFromNode($expr->getPropertyFetch(), $scope); - if ($propertyReflection === null) { - return new ErrorType(); - } - - 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/Virtual/PossiblyImpureCallExprHandler.php b/src/Analyser/ExprHandler/Virtual/PossiblyImpureCallExprHandler.php new file mode 100644 index 00000000000..e396fd2f3e3 --- /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 => $nativeTypesPromoted ? $nodeScopeResolver->readStoredOrPriceOnDemandNative($expr->callExpr, $scope) : $nodeScopeResolver->readStoredOrPriceOnDemand($expr->callExpr, $scope), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); + } + +} diff --git a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php index cd764c40dbf..c83e0dad9a5 100644 --- a/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetExistingOffsetValueTypeExprHandler.php @@ -2,23 +2,19 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; 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\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\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\SetExistingOffsetValueTypeExpr; use PHPStan\Type\Type; -use PHPStan\Type\UnionType; /** * @implements ExprHandler @@ -27,6 +23,10 @@ final class SetExistingOffsetValueTypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof SetExistingOffsetValueTypeExpr; @@ -34,37 +34,30 @@ 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 new ExpressionResult( + // 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, + beforeScope: $scope, + expr: $expr, hasYield: false, 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: static fn () => new SpecifiedTypes(), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varNode = $expr->getVar(); - $varType = $scope->getType($varNode); - if ($varNode instanceof OriginalPropertyTypeExpr) { - $currentPropertyType = $scope->getType($varNode->getPropertyFetch()); - if ($varType instanceof UnionType) { - $varType = $varType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); - } - } - 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 92c41c6b516..2ee2f3de31a 100644 --- a/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/SetOffsetValueTypeExprHandler.php @@ -2,23 +2,19 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; 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\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\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\SetOffsetValueTypeExpr; use PHPStan\Type\Type; -use PHPStan\Type\UnionType; /** * @implements ExprHandler @@ -27,6 +23,10 @@ final class SetOffsetValueTypeExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof SetOffsetValueTypeExpr; @@ -34,37 +34,31 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { - // 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 new 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())->setOffsetValueType( + $dimResult !== null ? ($nativeTypesPromoted ? $dimResult->getNativeType() : $dimResult->getType()) : null, + ($nativeTypesPromoted ? $valueResult->getNativeType() : $valueResult->getType()), + ), + specifyTypesCallback: static fn () => new SpecifiedTypes(), ); } - public function resolveType(MutatingScope $scope, Expr $expr): Type - { - $varNode = $expr->getVar(); - $varType = $scope->getType($varNode); - if ($varNode instanceof OriginalPropertyTypeExpr) { - $currentPropertyType = $scope->getType($varNode->getPropertyFetch()); - if ($varType instanceof UnionType) { - $varType = $varType->filterTypes(static fn (Type $innerType) => !$innerType->isSuperTypeOf($currentPropertyType)->no()); - } - } - 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 10467a171a5..b254440fa82 100644 --- a/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php +++ b/src/Analyser/ExprHandler/Virtual/StaticMethodCallableNodeHandler.php @@ -6,17 +6,17 @@ 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\StaticMethodCallableNode; -use PHPStan\Type\MixedType; +use PHPStan\Reflection\InitializerExprContext; +use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Type\Type; use function array_merge; @@ -27,6 +27,14 @@ final class StaticMethodCallableNodeHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + private InitializerExprTypeResolver $initializerExprTypeResolver, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof StaticMethodCallableNode; @@ -34,6 +42,7 @@ public function supports(Expr $expr): bool public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $throwPoints = []; $impurePoints = []; $hasYield = false; @@ -55,25 +64,17 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $nameResult->isAlwaysTerminating(); } - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: $hasYield, isAlwaysTerminating: $isAlwaysTerminating, throwPoints: $throwPoints, impurePoints: $impurePoints, + typeCallback: fn (bool $nativeTypesPromoted): Type => $this->initializerExprTypeResolver->getFirstClassCallableType($expr->getOriginalNode(), InitializerExprContext::fromScope($beforeScope), $nativeTypesPromoted), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $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 81c6c9f08f8..5394e117adf 100644 --- a/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/TypeExprHandler.php @@ -6,13 +6,12 @@ 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\Expr\TypeExpr; @@ -25,6 +24,13 @@ final class TypeExprHandler implements ExprHandler { + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) + { + } + public function supports(Expr $expr): bool { return $expr instanceof TypeExpr; @@ -35,23 +41,17 @@ 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 new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $scope, + expr: $expr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn (bool $nativeTypesPromoted): Type => $expr->getExprType(), + specifyTypesCallback: fn (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), ); } - 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); - } - } diff --git a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php index 0c2c4741831..d08f64cf232 100644 --- a/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php +++ b/src/Analyser/ExprHandler/Virtual/UnsetOffsetExprHandler.php @@ -2,18 +2,16 @@ namespace PHPStan\Analyser\ExprHandler\Virtual; +use PHPStan\Analyser\SpecifiedTypes; use PhpParser\Node\Expr; 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\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; @@ -25,6 +23,10 @@ final class UnsetOffsetExprHandler implements ExprHandler { + public function __construct(private ExpressionResultFactory $expressionResultFactory) + { + } + public function supports(Expr $expr): bool { return $expr instanceof UnsetOffsetExpr; @@ -32,26 +34,26 @@ 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 new ExpressionResult( + // 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->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: static fn () => new SpecifiedTypes(), ); } - 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); - } - } diff --git a/src/Analyser/ExprHandler/YieldFromHandler.php b/src/Analyser/ExprHandler/YieldFromHandler.php index 7b86f00abb2..7fcc9fbb34a 100644 --- a/src/Analyser/ExprHandler/YieldFromHandler.php +++ b/src/Analyser/ExprHandler/YieldFromHandler.php @@ -8,15 +8,14 @@ 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\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; @@ -31,39 +30,43 @@ final class YieldFromHandler implements ExprHandler { - public function supports(Expr $expr): bool + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { - return $expr instanceof YieldFrom; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function supports(Expr $expr): bool { - $yieldFromType = $scope->getType($expr->expr); - $generatorReturnType = $yieldFromType->getTemplateType(Generator::class, 'TReturn'); - if ($generatorReturnType instanceof ErrorType) { - return new MixedType(); - } - - return $generatorReturnType; + return $expr instanceof YieldFrom; } 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->enterDeep()); $scope = $exprResult->getScope(); - return new ExpressionResult( + return $this->expressionResultFactory->create( $scope, + beforeScope: $beforeScope, + expr: $expr, hasYield: true, 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 (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExprHandler/YieldHandler.php b/src/Analyser/ExprHandler/YieldHandler.php index 48fb166ee70..1c3cc3ec445 100644 --- a/src/Analyser/ExprHandler/YieldHandler.php +++ b/src/Analyser/ExprHandler/YieldHandler.php @@ -8,15 +8,14 @@ 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\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; @@ -31,29 +30,21 @@ final class YieldHandler implements ExprHandler { - public function supports(Expr $expr): bool + public function __construct( + private ExpressionResultFactory $expressionResultFactory, + private DefaultNarrowingHelper $defaultNarrowingHelper, + ) { - return $expr instanceof Yield_; } - public function resolveType(MutatingScope $scope, Expr $expr): Type + public function supports(Expr $expr): bool { - $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; + return $expr instanceof Yield_; } public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult { + $beforeScope = $scope; $throwPoints = [ InternalThrowPoint::createImplicit($scope, $expr), ]; @@ -82,18 +73,33 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex $isAlwaysTerminating = $isAlwaysTerminating || $valueResult->isAlwaysTerminating(); } - return new ExpressionResult( + // 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, + expr: $expr, hasYield: true, 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 (MutatingScope $s, TypeSpecifierContext $context) => $this->defaultNarrowingHelper->specifyDefaultTypes($expr, $context), + ); } } diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 746c518953d..d5be16d74e8 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -2,37 +2,77 @@ namespace PHPStan\Analyser; +use PhpParser\Node\Expr; +use PHPStan\DependencyInjection\GenerateFactory; +use PHPStan\DependencyInjection\Type\ExpressionTypeResolverExtensionRegistryProvider; +use PHPStan\ShouldNotHappenException; +use PHPStan\Type\Type; +use PHPStan\Type\TypeUtils; + +#[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(MutatingScope, TypeSpecifierContext): SpecifiedTypes */ + private $specifyTypesCallback; + + /** @var (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null */ + private $createTypesCallback; - /** @var (callable(): MutatingScope)|null */ - private $falseyScopeCallback; + private ?MutatingScope $truthyScope = null; private ?MutatingScope $falseyScope = null; + private ?Type $cachedType = null; + + private ?Type $cachedNativeType = 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(MutatingScope, TypeSpecifierContext): SpecifiedTypes $specifyTypesCallback + * @param (callable(MutatingScope, Type, TypeSpecifierContext): SpecifiedTypes)|null $createTypesCallback */ public function __construct( + private ExpressionTypeResolverExtensionRegistryProvider $expressionTypeResolverExtensionRegistryProvider, private MutatingScope $scope, + private MutatingScope $beforeScope, + private Expr $expr, private bool $hasYield, 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; } public function getScope(): MutatingScope @@ -40,11 +80,47 @@ public function getScope(): MutatingScope return $this->scope; } + public function getBeforeScope(): MutatingScope + { + return $this->beforeScope; + } + 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 = $useNativeTypes ? $this->getNativeTypeForScope($scope) : $this->getTypeForScope($scope); + + return new IssetabilityResolution( + IssetabilityLinkInfo::leaf($type, $this->expr, $this->expr instanceof Expr\NullsafePropertyFetch), + null, + ); + } + /** * @return InternalThrowPoint[] */ @@ -63,32 +139,40 @@ public function getImpurePoints(): array public function getTruthyScope(): MutatingScope { - if ($this->truthyScopeCallback === null) { - return $this->scope; - } - if ($this->truthyScope !== null) { return $this->truthyScope; } - $callback = $this->truthyScopeCallback; - $this->truthyScope = $callback(); - return $this->truthyScope; + // && 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; + } + + return $this->truthyScope = $this->scope->applySpecifiedTypes( + ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createTruthy()), + ); } public function getFalseyScope(): MutatingScope { - if ($this->falseyScopeCallback === null) { - return $this->scope; - } - if ($this->falseyScope !== null) { return $this->falseyScope; } - $callback = $this->falseyScopeCallback; - $this->falseyScope = $callback(); - return $this->falseyScope; + // || 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; + } + + return $this->falseyScope = $this->scope->applySpecifiedTypes( + ($this->specifyTypesCallback)($this->scope, TypeSpecifierContext::createFalsey()), + ); } public function isAlwaysTerminating(): bool @@ -96,4 +180,139 @@ public function isAlwaysTerminating(): bool return $this->isAlwaysTerminating; } + public function getType(): Type + { + if ($this->type !== null) { + return $this->type; + } + + if ($this->cachedType !== null) { + return $this->cachedType; + } + + foreach ($this->expressionTypeResolverExtensionRegistryProvider->getRegistry()->getExtensions() as $extension) { + $type = $extension->getType($this->expr, $this->beforeScope); + if ($type !== null) { + return $this->cachedType = $type; + } + } + + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope)) { + return $this->cachedType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(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 + { + if ($this->nativeType !== null) { + return $this->nativeType; + } + + if ($this->cachedNativeType !== null) { + return $this->cachedNativeType; + } + + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($this->beforeScope->doNotTreatPhpDocTypesAsCertain())) { + return $this->cachedNativeType = TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(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); + } + + /** + * 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->typeCallback !== null; + } + + /** Evaluates this expression's narrowing on the given scope. */ + public function getSpecifiedTypesForScope(MutatingScope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + return ($this->specifyTypesCallback)($scope, $context); + } + + /** + * 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 + { + if ($this->createTypesCallback === null) { + return null; + } + + return ($this->createTypesCallback)($scope, $type, $context); + } + + /** + * Re-evaluates the expression type on a different scope (e.g. a narrowed one). + * Unlike getType(), the result is not cached. + */ + public function getTypeForScope(MutatingScope $scope): Type + { + // A native-promoted scope asks getType() but means the native flavour + // (MutatingScope::getNativeType() promotes then calls getType()); the + // eager value is stored as a (phpdoc, native) pair, so honour the scope. + if ($this->nativeType !== null && $scope->nativeTypesPromoted) { + return $this->nativeType; + } + + if ($this->type !== null) { + return $this->type; + } + + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($scope)) { + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)($scope->nativeTypesPromoted)); + } + + return $scope->getTrackedExpressionType($this->expr); + } + + /** Native counterpart of getTypeForScope(). */ + public function getNativeTypeForScope(MutatingScope $scope): Type + { + if ($this->nativeType !== null) { + return $this->nativeType; + } + + $nativeScope = $scope->doNotTreatPhpDocTypesAsCertain(); + if ($this->typeCallback !== null && !$this->hasTrackedExpressionType($nativeScope)) { + return TypeUtils::resolveLateResolvableTypes(($this->typeCallback)(true)); + } + + return $nativeScope->getTrackedExpressionType($this->expr); + } + } diff --git a/src/Analyser/ExpressionResultFactory.php b/src/Analyser/ExpressionResultFactory.php new file mode 100644 index 00000000000..9c8af7d3f01 --- /dev/null +++ b/src/Analyser/ExpressionResultFactory.php @@ -0,0 +1,37 @@ + */ - private SplObjectStorage $scopes; + /** @var SplObjectStorage */ + private SplObjectStorage $exprResults; - /** @var array, request: BeforeScopeForExprRequest}> */ + /** + * Read-only fallback - writes never reach it. Makes duplicate() O(1) + * instead of copying all stored results. + */ + private ?self $fallback = null; + + /** @var array, request: ExpressionResultRequest}> */ public array $pendingFibers = []; - /** @var list> */ + /** @var list> */ public array $parkedFibers = []; public function __construct() { - $this->scopes = new SplObjectStorage(); + $this->exprResults = new SplObjectStorage(); } public function duplicate(): self { $new = new self(); - $new->scopes->addAll($this->scopes); + $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 { - $this->scopes[$expr] = $scope; + $this->exprResults[$expr] = $expressionResult; } - public function findBeforeScope(Expr $expr): ?Scope + public function findExpressionResult(Expr $expr): ?ExpressionResult { - return $this->scopes[$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/Fiber/BeforeScopeForExprRequest.php b/src/Analyser/Fiber/ExpressionResultRequest.php similarity index 61% rename from src/Analyser/Fiber/BeforeScopeForExprRequest.php rename to src/Analyser/Fiber/ExpressionResultRequest.php index 0fc6ecd35cd..4f3ecabdf97 100644 --- a/src/Analyser/Fiber/BeforeScopeForExprRequest.php +++ b/src/Analyser/Fiber/ExpressionResultRequest.php @@ -3,12 +3,11 @@ namespace PHPStan\Analyser\Fiber; use PhpParser\Node\Expr; -use PHPStan\Analyser\MutatingScope; -final class BeforeScopeForExprRequest +final class ExpressionResultRequest { - public function __construct(public readonly Expr $expr, public readonly MutatingScope $scope) + public function __construct(public readonly Expr $expr, public readonly FiberScope $scope) { } diff --git a/src/Analyser/Fiber/FiberNodeScopeResolver.php b/src/Analyser/Fiber/FiberNodeScopeResolver.php index e8d160f6a1d..6e7d2cd8df8 100644 --- a/src/Analyser/Fiber/FiberNodeScopeResolver.php +++ b/src/Analyser/Fiber/FiberNodeScopeResolver.php @@ -5,15 +5,20 @@ use Fiber; use PhpParser\Node; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\ExpressionResultStorage; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; +use PHPStan\Analyser\NoopNodeCallback; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\ShouldNotHappenException; 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 @@ -29,6 +34,12 @@ public function callNodeCallback( ExpressionResultStorage $storage, ): void { + 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; @@ -48,26 +59,26 @@ public function callNodeCallback( $this->runFiberForNodeCallback($storage, $fiber, $request); } - public function storeBeforeScope(ExpressionResultStorage $storage, Expr $expr, Scope $beforeScope): void + public function storeExpressionResult(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { - $storage->storeBeforeScope($expr, $beforeScope); - $this->processPendingFibersForRequestedExpr($storage, $expr, $beforeScope); + parent::storeExpressionResult($storage, $expr, $expressionResult); + $this->processPendingFibersForRequestedExpr($storage, $expr, $expressionResult); } /** - * @param Fiber $fiber + * @param Fiber $fiber */ private function runFiberForNodeCallback( ExpressionResultStorage $storage, Fiber $fiber, - BeforeScopeForExprRequest|ParkFiberRequest|null $request, + ExpressionResultRequest|ParkFiberRequest|null $request, ): void { while (!$fiber->isTerminated()) { - if ($request instanceof BeforeScopeForExprRequest) { - $beforeScope = $storage->findBeforeScope($request->expr); - if ($beforeScope !== null) { - $request = $fiber->resume($beforeScope); + if ($request instanceof ExpressionResultRequest) { + $expressionResult = $storage->findExpressionResult($request->expr); + if ($expressionResult !== null) { + $request = $fiber->resume($expressionResult); continue; } @@ -100,16 +111,48 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void foreach ($storage->pendingFibers as $key => $pending) { $request = $pending['request']; - $beforeScope = $storage->findBeforeScope($request->expr); - if ($beforeScope !== null) { + // 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[spl_object_id($request->expr)])) { + continue; + } + + $expressionResult = $storage->findExpressionResult($request->expr); + + 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. Guard kept dormant; enable with PHPSTAN_GUARD_NW=1. + if (self::$guardNewWorld && isset(self::$guardRealExprIds[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(), + )); + } + unset($storage->pendingFibers[$key]); $fiber = $pending['fiber']; - $request = $fiber->resume($request->scope); + + // Process the synthetic 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, + $request->scope->toMutatingScope(), + $storage->duplicate(), + ); + $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); // Break and restart the loop since the array may have been modified @@ -117,7 +160,7 @@ protected function processPendingFibers(ExpressionResultStorage $storage): void } } - private function processPendingFibersForRequestedExpr(ExpressionResultStorage $storage, Expr $expr, Scope $result): void + private function processPendingFibersForRequestedExpr(ExpressionResultStorage $storage, Expr $expr, ExpressionResult $expressionResult): void { start: @@ -130,7 +173,7 @@ private function processPendingFibersForRequestedExpr(ExpressionResultStorage $s unset($storage->pendingFibers[$key]); $fiber = $pending['fiber']; - $request = $fiber->resume($result); + $request = $fiber->resume($expressionResult); $this->runFiberForNodeCallback($storage, $fiber, $request); // Break and restart the loop since the array may have been modified diff --git a/src/Analyser/Fiber/FiberScope.php b/src/Analyser/Fiber/FiberScope.php index b02f322c358..698cf7fa1d0 100644 --- a/src/Analyser/Fiber/FiberScope.php +++ b/src/Analyser/Fiber/FiberScope.php @@ -4,6 +4,7 @@ use Fiber; use PhpParser\Node\Expr; +use PHPStan\Analyser\ExpressionResult; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; @@ -11,6 +12,7 @@ use PHPStan\Reflection\ParameterReflection; use PHPStan\Type\Type; use function array_pop; +use function count; final class FiberScope extends MutatingScope { @@ -57,12 +59,20 @@ public function toMutatingScope(): MutatingScope /** @api */ public function getType(Expr $node): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), + /** @var ExpressionResult $expressionResult */ + $expressionResult = Fiber::suspend( + new ExpressionResultRequest($node, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); + if ( + !$this->nativeTypesPromoted + && count($this->truthyValueExprs) === 0 + && count($this->falseyValueExprs) === 0 + ) { + return $expressionResult->getType(); + } + + $scope = $this->preprocessScope($expressionResult->getBeforeScope()); return $scope->getType($node); } @@ -79,23 +89,31 @@ public function getScopeNativeType(Expr $expr): Type /** @api */ public function getNativeType(Expr $expr): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($expr, $this), + /** @var ExpressionResult $expressionResult */ + $expressionResult = Fiber::suspend( + new ExpressionResultRequest($expr, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); + if ( + !$this->nativeTypesPromoted + && count($this->truthyValueExprs) === 0 + && count($this->falseyValueExprs) === 0 + ) { + return $expressionResult->getNativeType(); + } + + $scope = $this->preprocessScope($expressionResult->getBeforeScope()); return $scope->getNativeType($expr); } public function getKeepVoidType(Expr $node): Type { - /** @var Scope $beforeScope */ - $beforeScope = Fiber::suspend( - new BeforeScopeForExprRequest($node, $this), + /** @var ExpressionResult $expressionResult */ + $expressionResult = Fiber::suspend( + new ExpressionResultRequest($node, $this), ); - $scope = $this->preprocessScope($beforeScope->toMutatingScope()); + $scope = $this->preprocessScope($expressionResult->getBeforeScope()); return $scope->getKeepVoidType($node); } diff --git a/src/Analyser/IssetabilityDescriptor.php b/src/Analyser/IssetabilityDescriptor.php new file mode 100644 index 00000000000..501da858ed4 --- /dev/null +++ b/src/Analyser/IssetabilityDescriptor.php @@ -0,0 +1,162 @@ +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 = $useNativeTypes ? $varResult->getNativeTypeForScope($scope) : $varResult->getTypeForScope($scope); + $dimType = $useNativeTypes ? $dimResult->getNativeTypeForScope($scope) : $dimResult->getTypeForScope($scope); + $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), + $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(), + $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..a347a8039fd --- /dev/null +++ b/src/Analyser/IssetabilityLinkInfo.php @@ -0,0 +1,292 @@ +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; + } + + 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..78365891310 --- /dev/null +++ b/src/Analyser/IssetabilityResolution.php @@ -0,0 +1,177 @@ +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?->isSetUndefined(); + } + + $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?->isSetUndefined(); + } + + if ( + $link->hasNativeType() + && !$link->isVirtual()->yes() + && !$link->hasExpressionTypeOfFetch() + && !$link->nativeHasDefaultValue() + && (!$link->nativeReflectionExists() || !$link->nativeIsPromoted() || (!$link->nativeIsReadOnly() && !$link->nativeIsHooked())) + ) { + return $this->inner?->isSetUndefined(); + } + + 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?->isSetUndefined(); + } + + if (!$link->getHasOffsetValue()->no()) { + return $this->inner?->isSetUndefined(); + } + + return false; + } + + if ($link->isProperty()) { + return $this->inner?->isSetUndefined(); + } + + 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 30bad59a9a4..b554cc1a3dd 100644 --- a/src/Analyser/LazyInternalScopeFactory.php +++ b/src/Analyser/LazyInternalScopeFactory.php @@ -41,6 +41,8 @@ final class LazyInternalScopeFactory implements InternalScopeFactory private ?ConstantResolver $constantResolver = null; + private ExpressionResultStorageStack $expressionResultStorageStack; + private ?PhpVersion $phpVersionType = null; private ?AttributeReflectionFactory $attributeReflectionFactory = null; @@ -52,10 +54,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( @@ -105,6 +109,7 @@ public function create( $this->propertyReflectionFinder, $this->currentSimpleVersionParser, $this->constantResolver, + $this->expressionResultStorageStack, $context, $this->phpVersionType, $this->attributeReflectionFactory, @@ -130,12 +135,12 @@ public function create( public function toFiberFactory(): InternalScopeFactory { - return new self($this->container, $this->nodeCallback, true); + return new self($this->container, $this->nodeCallback, true, $this->expressionResultStorageStack); } public function toMutatingFactory(): InternalScopeFactory { - return new self($this->container, $this->nodeCallback, false); + return new self($this->container, $this->nodeCallback, false, $this->expressionResultStorageStack); } } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index e02c18b0da3..969a056295d 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -22,14 +22,15 @@ 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; use PHPStan\Node\EmitCollectedDataNode; use PHPStan\Node\Expr\AlwaysRememberedExpr; use PHPStan\Node\Expr\CloneReinitializationExpr; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr; @@ -67,6 +68,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasOffsetValueType; +use PHPStan\Type\Accessory\HasPropertyType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\Accessory\OversizedArrayType; use PHPStan\Type\ArrayType; @@ -126,6 +128,7 @@ use function is_string; use function ltrim; use function md5; +use function spl_object_id; use function sprintf; use function str_contains; use function str_starts_with; @@ -186,6 +189,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, @@ -893,9 +897,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(), + )); + } + $key = $this->getNodeKey($node); if (!array_key_exists($key, $this->resolvedTypes)) { @@ -984,182 +1030,210 @@ private function resolveType(string $exprString, Expr $node): Type return $this->expressionTypes[$exprString]->getType(); } + // NodeScopeResolver intercepts a first-class callable CallLike before the + // ExprHandler loop - 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); + } + /** @var ExprHandler $exprHandler */ foreach ($this->container->getServicesByTag(ExprHandler::EXTENSION_TAG) as $exprHandler) { if (!$exprHandler->supports($node)) { continue; } - 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)); - } - - 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); - } - - $dimType = $this->getType($expr->dim); - $hasOffsetValue = $type->hasOffsetValueType($dimType); - if ($hasOffsetValue->no()) { - return false; - } - - // 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)); + 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(); + if ($storage !== null) { + $result = $storage->findExpressionResult($node); + if ($result !== null && $result->canResolveOwnType()) { + return $scope->nativeTypesPromoted ? $result->getNativeTypeForScope($scope) : $result->getTypeForScope($scope); + } + } + + // 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); + } + + if ($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), + )); + } + + // 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 $scope->nativeTypesPromoted ? $onDemandResult->getNativeTypeForScope($scope) : $onDemandResult->getTypeForScope($scope); + } - // 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); - } - - return null; - } + $result = $storage->findExpressionResult($expr); + if ($result === null) { + // a synthetic node - price it on demand, see + // resolveTypeOfNewWorldHandlerNode() + $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->isPromoted() || (!$nativeReflection->isReadOnly() && !$nativeReflection->isHooked())) { - if ($expr instanceof Node\Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } + return [ + $result->getTypeForScope($scope), + $result->getNativeTypeForScope($scope), + ]; + } - if ($expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } + // re-evaluate on the asking scope, not the stored beforeScope: a handler + // (e.g. isset/empty via NonNullabilityHelper) may have processed the + // inner expression on a scope that strips null, so the cached type would + // be stale for the narrowing the caller is applying + return [ + $result->getTypeForScope($this), + $result->getNativeTypeForScope($this), + ]; + } - 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()/getTypeForScope()): 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; - } - - return $typeCallback($this->getType($expr)); + 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(), + )); + } + + // 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); - } - - return false; - } - - if ($expr instanceof Expr\PropertyFetch) { - return $this->issetCheckUndefined($expr->var); - } + $this->expressionResultStorageStack->push($storage); + } - if ($expr instanceof Expr\StaticPropertyFetch && $expr->class instanceof Expr) { - return $this->issetCheckUndefined($expr->class); - } + public function popExpressionResultStorage(): void + { + $this->expressionResultStorageStack->pop(); + } - return null; + /** + * 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 + */ + public function getCurrentExpressionResultStorage(): ?ExpressionResultStorage + { + return $this->expressionResultStorageStack->getCurrent(); } /** @api */ @@ -1181,10 +1255,10 @@ 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; } @@ -1195,7 +1269,7 @@ public function getKeepVoidType(Expr $node): Type return $this->getType($clonedNode); } - public function doNotTreatPhpDocTypesAsCertain(): Scope + public function doNotTreatPhpDocTypesAsCertain(): self { return $this->promoteNativeTypes(); } @@ -1310,6 +1384,19 @@ public function hasExpressionType(Expr $node): TrinaryLogic return $this->expressionTypes[$exprString]->getCertainty(); } + /** + * 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 */ @@ -1668,7 +1755,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; @@ -1990,10 +2077,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, shallow: true); $scope = $this->enterAnonymousFunctionWithoutReflection($closure, $callableParameters, $nativeCallableParameters); @@ -2181,7 +2265,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(); } @@ -2213,10 +2297,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, shallow: true); $scope = $this->enterArrowFunctionWithoutReflection($arrowFunction, $callableParameters, $nativeCallableParameters); @@ -2415,10 +2496,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( @@ -2436,7 +2515,10 @@ public function enterForeach(self $originalScope, Expr $iteratee, string $valueN $scope = $scope->assignExpression( new IntertwinedVariableByReferenceWithExpr($valueName, $iteratee, new SetExistingOffsetValueTypeExpr( $iteratee, - new GetIterableKeyTypeExpr($iteratee), + new NativeTypeExpr( + $originalScope->getIterableKeyType($iterateeType), + $originalScope->getIterableKeyType($nativeIterateeType), + ), new Variable($valueName), )), $valueType, @@ -2444,7 +2526,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( @@ -2458,11 +2540,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); @@ -2702,8 +2781,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 ( @@ -2717,14 +2801,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), ); } @@ -2751,6 +2836,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) { @@ -2775,10 +2891,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( @@ -2796,11 +2912,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), ), ); } @@ -2809,6 +2925,77 @@ private function unsetExpression(Expr $expr): self return $scope->invalidateExpression($expr); } + 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) { + return $this->resolveScopeStateType($expr->var, $native) + ->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(); + } + + // 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) { @@ -2842,9 +3029,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 = $scope->getScopeStateType($expr->dim)->toArrayKey(); if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) { - $exprVarType = $scope->getType($expr->var); + $exprVarType = $scope->getScopeStateType($expr->var); $isArray = $exprVarType->isArray(); if (!$exprVarType instanceof MixedType && !$isArray->no()) { $varType = $exprVarType; @@ -2868,7 +3055,7 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, $scope = $scope->specifyExpressionType( $expr->var, $varType, - $scope->getNativeType($expr->var), + $scope->getScopeStateNativeType($expr->var), $certainty, ); } @@ -3218,6 +3405,33 @@ private function setExpressionCertainty(Expr $expr, TrinaryLogic $certainty): se ); } + /** + * Certainty change for applySpecifiedTypes(): unlike setExpressionCertainty(), + * 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 + { + $exprString = $this->getNodeKey($expr); + if (!array_key_exists($exprString, $this->expressionTypes)) { + throw new ShouldNotHappenException(); + } + + $exprType = $this->expressionTypes[$exprString]->getType(); + $nativeType = array_key_exists($exprString, $this->nativeExpressionTypes) + ? $this->nativeExpressionTypes[$exprString]->getType() + : $exprType; + + return $this->specifyExpressionType( + $expr, + $exprType, + $nativeType, + $certainty, + ); + } + /** * Returns true when the type is a large union with intersection * members that carry HasOffsetValueType — a sign of combinatorial @@ -3248,12 +3462,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); @@ -3274,7 +3488,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; } @@ -3286,7 +3500,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(), ); } @@ -3385,6 +3599,17 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self continue; } + if ( + !$typeSpecification['sure'] + && $expr instanceof Variable && is_string($expr->name) + && $scope->hasVariableType($expr->name)->no() + ) { + // removing type from a certainly-undefined variable cannot make + // it defined; a sure specification (e.g. is_string($a)) still can - + // the condition can only hold for a defined variable + continue; + } + if ($typeSpecification['sure']) { if ($specifiedTypes->shouldOverwrite()) { $scope = $scope->assignExpression($expr, $type, $type); @@ -3397,6 +3622,198 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $specifiedExpressions[$typeSpecification['exprString']] = ExpressionTypeHolder::createYes($expr, $scope->getScopeType($expr)); } + $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); + + /** @var static */ + return $scope->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), + $scope->expressionTypes, + $scope->nativeExpressionTypes, + $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), + $scope->inClosureBindScopeClasses, + $scope->anonymousFunctionReflection, + $scope->inFirstLevelStatement, + $scope->currentlyAssignedExpressions, + $scope->currentlyAllowedUndefinedExpressions, + $scope->inFunctionCallsStack, + $scope->afterExtractCall, + $scope->parentScope, + $scope->nativeTypesPromoted, + ); + } + + /** + * New-world counterpart of filterBySpecifiedTypes. + * + * 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. + */ + public function applySpecifiedTypes(SpecifiedTypes $specifiedTypes): self + { + $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' => $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, + ]; + } + + 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; + $specifiedExpressions = []; + foreach ($typeSpecifications as $typeSpecification) { + $expr = $typeSpecification['expr']; + $type = $typeSpecification['type']; + $exprString = $typeSpecification['exprString']; + + if ($expr instanceof IssetExpr) { + $issetExpr = $expr; + $expr = $issetExpr->getExpr(); + + if ($typeSpecification['sure']) { + $scope = $scope->setExpressionCertaintyKeepingType( + $expr, + TrinaryLogic::createMaybe(), + ); + } else { + $scope = $scope->unsetExpression($expr); + } + + continue; + } + + if ( + !$typeSpecification['sure'] + && $expr instanceof Variable && is_string($expr->name) + && $scope->hasVariableType($expr->name)->no() + ) { + // removing type from a certainly-undefined variable cannot make + // it defined; a sure specification (e.g. is_string($a)) still can - + // the condition can only hold for a defined variable + 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 ($typeSpecification['sure']) { + if ($specifiedTypes->shouldOverwrite()) { + $scope = $scope->assignExpression($expr, $type, $type); + } else { + $newType = $trackedType !== null ? TypeCombinator::intersect($type, $trackedType) : $type; + $newNativeType = $trackedNativeType !== null ? TypeCombinator::intersect($type, $trackedNativeType) : $type; + $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } + } else { + if ($type instanceof NeverType || $trackedType instanceof NeverType) { + continue; + } + $newType = $trackedType !== null ? TypeCombinator::remove($trackedType, $type) : null; + if ($newType === null) { + // the expression is not tracked - there is nothing to subtract from + continue; + } + $newNativeType = $trackedNativeType !== null ? TypeCombinator::remove($trackedNativeType, $type) : $newType; + $scope = $scope->specifyExpressionType($expr, $newType, $newNativeType, TrinaryLogic::createYes()); + } + + $holderType = array_key_exists($exprString, $scope->expressionTypes) + ? $scope->expressionTypes[$exprString]->getType() + : $type; + $specifiedExpressions[$exprString] = ExpressionTypeHolder::createYes($expr, $holderType); + } + + $scope = $scope->processConditionalExpressionsAfterSpecifying($specifiedExpressions); + + /** @var static */ + return $scope->scopeFactory->create( + $scope->context, + $scope->isDeclareStrictTypes(), + $scope->getFunction(), + $scope->getNamespace(), + $scope->expressionTypes, + $scope->nativeExpressionTypes, + $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), + $scope->inClosureBindScopeClasses, + $scope->anonymousFunctionReflection, + $scope->inFirstLevelStatement, + $scope->currentlyAssignedExpressions, + $scope->currentlyAllowedUndefinedExpressions, + $scope->inFunctionCallsStack, + $scope->afterExtractCall, + $scope->parentScope, + $scope->nativeTypesPromoted, + ); + } + + /** + * 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 processConditionalExpressionsAfterSpecifying(array $specifiedExpressions): self + { + $scope = $this; $conditions = []; $originallySpecifiedExprStrings = $specifiedExpressions; $prevSpecifiedCount = -1; @@ -3475,25 +3892,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self } } - /** @var static */ - return $scope->scopeFactory->create( - $scope->context, - $scope->isDeclareStrictTypes(), - $scope->getFunction(), - $scope->getNamespace(), - $scope->expressionTypes, - $scope->nativeExpressionTypes, - $this->mergeConditionalExpressions($specifiedTypes->getNewConditionalExpressionHolders(), $scope->conditionalExpressions), - $scope->inClosureBindScopeClasses, - $scope->anonymousFunctionReflection, - $scope->inFirstLevelStatement, - $scope->currentlyAssignedExpressions, - $scope->currentlyAllowedUndefinedExpressions, - $scope->inFunctionCallsStack, - $scope->afterExtractCall, - $scope->parentScope, - $scope->nativeTypesPromoted, - ); + return $scope; } /** @@ -4895,6 +5294,13 @@ public function getInstancePropertyReflection(Type $typeWithProperty, string $pr if ($typeWithProperty instanceof UnionType) { $typeWithProperty = $typeWithProperty->filterTypes(static fn (Type $innerType) => $innerType->hasInstanceProperty($propertyName)->yes()); } + if ($typeWithProperty->hasInstanceProperty($propertyName)->maybe()) { + // an optional property (e.g. object{foo?: int}) only maybe exists, so its + // reflection is not directly queryable. Asserting its existence via + // HasPropertyType makes it yes and exposes the declared type - which is the + // only useful type for `$object->foo`; whether it exists is a separate check. + $typeWithProperty = TypeCombinator::intersect($typeWithProperty, new HasPropertyType($propertyName)); + } if (!$typeWithProperty->hasInstanceProperty($propertyName)->yes()) { return null; } @@ -4908,6 +5314,11 @@ public function getStaticPropertyReflection(Type $typeWithProperty, string $prop if ($typeWithProperty instanceof UnionType) { $typeWithProperty = $typeWithProperty->filterTypes(static fn (Type $innerType) => $innerType->hasStaticProperty($propertyName)->yes()); } + if ($typeWithProperty->hasStaticProperty($propertyName)->maybe()) { + // mirror getInstancePropertyReflection(): assert a maybe-existing property + // so its declared type becomes queryable + $typeWithProperty = TypeCombinator::intersect($typeWithProperty, new HasPropertyType($propertyName)); + } if (!$typeWithProperty->hasStaticProperty($propertyName)->yes()) { return null; } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 3f63434d03f..287fb08efcf 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -51,6 +51,7 @@ use PhpParser\NodeTraverser; use PhpParser\NodeVisitorAbstract; use PHPStan\Analyser\ExprHandler\AssignHandler; +use PHPStan\Analyser\ExprHandler\Helper\ClosureTypeResolver; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\BetterReflection\Reflection\ReflectionEnum; @@ -78,14 +79,14 @@ use PHPStan\Node\ExecutionEndNode; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\ForeachValueByRefExpr; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; -use PHPStan\Node\Expr\GetIterableValueTypeExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; use PHPStan\Node\Expr\TypeExpr; use PHPStan\Node\Expr\UnsetOffsetExpr; use PHPStan\Node\FinallyExitPointsNode; +use PHPStan\Node\FunctionCallExpressionNode; use PHPStan\Node\FunctionCallableNode; use PHPStan\Node\FunctionReturnStatementsNode; use PHPStan\Node\InArrowFunctionNode; @@ -98,6 +99,7 @@ use PHPStan\Node\InstantiationCallableNode; use PHPStan\Node\InTraitNode; use PHPStan\Node\InvalidateExprNode; +use PHPStan\Node\MethodCallExpressionNode; use PHPStan\Node\MethodCallableNode; use PHPStan\Node\MethodReturnStatementsNode; use PHPStan\Node\NoopExpressionNode; @@ -105,6 +107,7 @@ use PHPStan\Node\PropertyHookReturnStatementsNode; use PHPStan\Node\PropertyHookStatementNode; use PHPStan\Node\ReturnStatement; +use PHPStan\Node\StaticMethodCallExpressionNode; use PHPStan\Node\StaticMethodCallableNode; use PHPStan\Node\UnreachableStatementNode; use PHPStan\Node\VariableAssignNode; @@ -175,10 +178,12 @@ use function array_slice; use function array_values; use function count; +use function getenv; use function in_array; use function is_array; use function is_int; use function is_string; +use function spl_object_id; use function sprintf; use function strtolower; use function trim; @@ -197,9 +202,6 @@ class NodeScopeResolver /** @var array filePath(string) => bool(true) */ private array $analysedFiles = []; - /** @var array */ - private array $earlyTerminatingMethodNames; - /** @var array */ private array $calledMethodStack = []; @@ -207,9 +209,43 @@ class NodeScopeResolver private array $calledMethodResults = []; /** - * @param string[][] $earlyTerminatingMethodCalls className(string) => methods(string[]) - * @param array $earlyTerminatingFunctionCalls + * 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; + + /** + * 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 = []; + public function __construct( private readonly Container $container, private readonly ReflectionProvider $reflectionProvider, @@ -223,7 +259,6 @@ public function __construct( private readonly FileTypeMapper $fileTypeMapper, private readonly PhpDocInheritanceResolver $phpDocInheritanceResolver, private readonly FileHelper $fileHelper, - private readonly TypeSpecifier $typeSpecifier, private readonly ReadWritePropertiesExtensionProvider $readWritePropertiesExtensionProvider, private readonly ParameterClosureThisExtensionProvider $parameterClosureThisExtensionProvider, private readonly ParameterClosureTypeExtensionProvider $parameterClosureTypeExtensionProvider, @@ -235,24 +270,15 @@ public function __construct( private readonly bool $polluteScopeWithAlwaysIterableForeach, #[AutowiredParameter] private readonly bool $polluteScopeWithBlock, - #[AutowiredParameter] - private readonly array $earlyTerminatingMethodCalls, - #[AutowiredParameter] - private readonly array $earlyTerminatingFunctionCalls, #[AutowiredParameter(ref: '%exceptions.implicitThrows%')] private readonly bool $implicitThrows, #[AutowiredParameter] private readonly bool $treatPhpDocTypesAsCertain, private readonly ImplicitToStringCallHelper $implicitToStringCallHelper, + private readonly ExpressionResultFactory $expressionResultFactory, ) { - $earlyTerminatingMethodNames = []; - foreach ($this->earlyTerminatingMethodCalls as $methodNames) { - foreach ($methodNames as $methodName) { - $earlyTerminatingMethodNames[strtolower($methodName)] = true; - } - } - $this->earlyTerminatingMethodNames = $earlyTerminatingMethodNames; + self::$guardNewWorld = getenv('PHPSTAN_GUARD_NW') === '1'; } /** @@ -275,7 +301,34 @@ 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; + } + } + $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 = []; @@ -353,14 +406,36 @@ public function processNodes( $this->processPendingFibers($expressionResultStorage); } - public function storeBeforeScope(ExpressionResultStorage $storage, Expr $expr, Scope $beforeScope): void + 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 @@ -491,14 +566,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(); + } } /** @@ -522,7 +602,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; } @@ -957,7 +1052,7 @@ 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 { + $statementResult = $this->processStmtNodesInternal($stmt, $stmt->stmts, $methodScope, $storage, function (Node $node, Scope $scope) use ($nodeCallback, $methodScope, &$gatheredReturnStatements, &$gatheredYieldStatements, &$executionEnds, &$methodImpurePoints): void { $nodeCallback($node, $scope); if ($scope->getFunction() !== $methodScope->getFunction()) { return; @@ -971,7 +1066,7 @@ public function processStmtNode( && $scope->getFunction() instanceof PhpMethodFromParserNodeReflection && $scope->getFunction()->getDeclaringClass()->hasConstructor() && $scope->getFunction()->getDeclaringClass()->getConstructor()->getName() === $scope->getFunction()->getName() - && TypeUtils::findThisType($scope->getType($node->getPropertyFetch()->var)) !== null + && TypeUtils::findThisType($this->readStoredOrPriceOnDemand($node->getPropertyFetch()->var, $scope->toMutatingScope())) !== null ) { return; } @@ -1058,7 +1153,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(); @@ -1117,7 +1212,6 @@ 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 { @@ -1142,17 +1236,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); @@ -1300,9 +1396,9 @@ public function processStmtNode( $this->callNodeCallback($nodeCallback, $stmt->type, $scope, $storage); } } elseif ($stmt instanceof If_) { - $conditionType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); - $ifAlwaysTrue = $conditionType->isTrue()->yes(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $conditionType = ($this->treatPhpDocTypesAsCertain ? $condResult->getType() : $condResult->getNativeType())->toBoolean(); + $ifAlwaysTrue = $conditionType->isTrue()->yes(); $exitPoints = []; $throwPoints = $overridingThrowPoints ?? $condResult->getThrowPoints(); $impurePoints = $condResult->getImpurePoints(); @@ -1336,8 +1432,8 @@ public function processStmtNode( $condScope = $scope; foreach ($stmt->elseifs as $elseif) { $this->callNodeCallback($nodeCallback, $elseif, $scope, $storage); - $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condScope->getType($elseif->cond) : $scope->getNativeType($elseif->cond))->toBoolean(); $condResult = $this->processExprNode($stmt, $elseif->cond, $condScope, $storage, $nodeCallback, ExpressionContext::createDeep()); + $elseIfConditionType = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($condScope) : $condResult->getNativeTypeForScope($scope))->toBoolean(); $throwPoints = array_merge($throwPoints, $condResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $condResult->getImpurePoints()); $branchScopeStatementResult = $this->processStmtNodesInternal($elseif, $elseif->stmts, $condResult->getTruthyScope(), $storage, $nodeCallback, $context); @@ -1414,10 +1510,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); @@ -1434,14 +1540,28 @@ public function processStmtNode( $originalScope = $scope; $bodyScope = $scope; + $foreachIterateeType = $condResult->getTypeForScope($originalScope); + $foreachNativeIterateeType = $condResult->getNativeTypeForScope($originalScope); + if ($stmt->keyVar instanceof Variable) { - $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->keyVar, new GetIterableKeyTypeExpr($stmt->expr)), $originalScope, $storage); + $keyTypeExpr = new NativeTypeExpr( + $originalScope->getIterableKeyType($foreachIterateeType), + $originalScope->getIterableKeyType($foreachNativeIterateeType), + ); + $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->keyVar, $keyTypeExpr), $originalScope, $storage); } if ($stmt->valueVar instanceof Variable) { - $this->callNodeCallback($nodeCallback, new VariableAssignNode($stmt->valueVar, new GetIterableValueTypeExpr($stmt->expr)), $originalScope, $storage); + $valueTypeExpr = new NativeTypeExpr( + $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 GetIterableValueTypeExpr($stmt->expr)); + $virtualAssign = new Assign($stmt->valueVar, new NativeTypeExpr( + $originalScope->getIterableValueType($foreachIterateeType), + $originalScope->getIterableValueType($foreachNativeIterateeType), + )); $virtualAssign->setAttributes($stmt->valueVar->getAttributes()); $this->callNodeCallback($nodeCallback, $virtualAssign, $scope, $storage); } @@ -1452,20 +1572,26 @@ public function processStmtNode( if ($context->isTopLevel()) { $storage = $originalStorage->duplicate(); - $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope; - $unrolledResult = $this->tryProcessUnrolledConstantArrayForeach($stmt, $originalScope, $originalStorage, $context); + $originalScope = $this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope; + // $originalScope may narrow the iteratee to a non-empty array - a genuinely + // different scope than its own - so reprocess it there rather than re-running + // its result on a foreign scope. + $iterateeResult = $this->processExprOnDemand($stmt->expr, $originalScope, new ExpressionResultStorage()); + $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); + $bodyScope = $this->enterForeach($originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); $count = 0; do { $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope); $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($bodyScope, $storage, $originalScope, $stmt, $nodeCallback); + $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) { @@ -1483,9 +1609,9 @@ public function processStmtNode( } } - $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope); + $bodyScope = $bodyScope->mergeWith($this->polluteScopeWithAlwaysIterableForeach ? $this->narrowScopeWithCondition($scope, $arrayComparisonExpr, TypeSpecifierContext::createTruthy()) : $scope); $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(); @@ -1535,7 +1661,11 @@ 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 genuinely different scope than the iteratee's own, + // so reprocess it there to observe the modified type. + $iterateeResult = $this->processExprOnDemand($stmt->expr, $scope, new ExpressionResultStorage()); + $exprType = $iterateeResult->getType(); $hasExpr = $scope->hasExpressionType($stmt->expr); if ( count($breakExitPoints) === 0 @@ -1553,8 +1683,8 @@ public function processStmtNode( foreach ($scopesWithIterableValueType as $scopeWithIterableValueType) { if ($keyVarExpr !== null) { $arrayExprDimFetch = new ArrayDimFetch($stmt->expr, $keyVarExpr); - $dimFetchType = $scopeWithIterableValueType->getType($arrayExprDimFetch); - $dimFetchNativeType = $scopeWithIterableValueType->getNativeType($arrayExprDimFetch); + $dimFetchType = $this->priceSyntheticOnDemand($arrayExprDimFetch, $scopeWithIterableValueType); + $dimFetchNativeType = $this->priceSyntheticOnDemand($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 @@ -1562,21 +1692,29 @@ public function processStmtNode( // the narrowed value-var type in place of the broader dim fetch type so // the loop's final array rewrite below picks up the sharper element type. if ($originalValueExpr !== null && $scopeWithIterableValueType->hasExpressionType($originalValueExpr)->yes()) { - $valueVarType = $scopeWithIterableValueType->getType($stmt->valueVar); + // read the loop value variable's narrowed type directly by name - + // 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->getType($keyVarExpr); + $keyLoopTypes[] = $this->readStoredOrPriceOnDemand($keyVarExpr, $scopeWithIterableValueType); + $keyLoopNativeTypes[] = $this->readStoredOrPriceOnDemand($keyVarExpr, $scopeWithIterableValueType); } else { - // No key variable: the narrowed value var is the array element type directly. - $dimFetchType = $scopeWithIterableValueType->getType($stmt->valueVar); - $dimFetchNativeType = $scopeWithIterableValueType->getNativeType($stmt->valueVar); + // 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->doNotTreatPhpDocTypesAsCertain()->getVariableType($stmt->valueVar->name); } $arrayDimFetchLoopTypes[] = $dimFetchType; $arrayDimFetchLoopNativeTypes[] = $dimFetchNativeType; @@ -1588,7 +1726,7 @@ public function processStmtNode( $valueTypeChanged = !$arrayDimFetchLoopType->equals($exprType->getIterableValueType()); $keyTypeChanged = false; $keyLoopType = $exprType->getIterableKeyType(); - $keyLoopNativeType = $scope->getNativeType($stmt->expr)->getIterableKeyType(); + $keyLoopNativeType = $iterateeResult->getNativeType()->getIterableKeyType(); if ($keyVarExpr !== null) { $keyLoopType = TypeCombinator::union(...$keyLoopTypes); $keyLoopNativeType = TypeCombinator::union(...$keyLoopNativeTypes); @@ -1604,7 +1742,7 @@ public function processStmtNode( $newExprType = $newExprType->mapKeyType(static fn (Type $type): Type => $keyLoopType); } - $nativeExprType = $scope->getNativeType($stmt->expr); + $nativeExprType = $iterateeResult->getNativeType(); $newExprNativeType = $nativeExprType; if ($valueTypeChanged) { $newExprNativeType = $newExprNativeType->mapValueType(static fn (Type $type): Type => $arrayDimFetchLoopNativeType); @@ -1632,7 +1770,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_([]), @@ -1640,7 +1778,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) { @@ -1652,7 +1790,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; } @@ -1672,7 +1810,7 @@ public function processStmtNode( $originalStorage = $storage; $storage = $originalStorage->duplicate(); $condResult = $this->processExprNode($stmt, $stmt->cond, $scope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep()); - $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $scope->getType($stmt->cond) : $scope->getNativeType($stmt->cond))->toBoolean(); + $beforeCondBooleanType = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($scope) : $condResult->getNativeTypeForScope($scope))->toBoolean(); $condScope = $condResult->getFalseyScope(); if (!$context->isTopLevel() && $beforeCondBooleanType->isFalse()->yes()) { if (!$this->polluteScopeWithLoopInitialAssignments) { @@ -1716,14 +1854,19 @@ 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. + $finalScope = $finalScope->applySpecifiedTypes($this->processExprOnDemand($stmt->cond, $finalScope, new ExpressionResultStorage())->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->getTypeForScope($bodyScopeMaybeRan) : $bodyCondResult->getTypeForScope($bodyScopeMaybeRan->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); $neverIterates = $condBooleanType->isFalse()->yes(); } @@ -1821,7 +1964,7 @@ public function processStmtNode( $alwaysIterates = false; if ($context->isTopLevel()) { - $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $bodyScope->getType($stmt->cond) : $bodyScope->getNativeType($stmt->cond))->toBoolean(); + $condBooleanType = ($this->treatPhpDocTypesAsCertain ? $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScope) : $this->readStoredOrPriceOnDemand($stmt->cond, $bodyScope->doNotTreatPhpDocTypesAsCertain()))->toBoolean(); $alwaysIterates = $condBooleanType->isTrue()->yes(); } @@ -1892,7 +2035,7 @@ public function processStmtNode( // 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 ? $condResultScope->getType($condExpr) : $condResultScope->getNativeType($condExpr))->toBoolean(); + $condTruthiness = ($this->treatPhpDocTypesAsCertain ? $condResult->getTypeForScope($condResultScope) : $condResult->getNativeTypeForScope($condResultScope))->toBoolean(); $isIterableAtLeastOnce = $isIterableAtLeastOnce->and($condTruthiness->isTrue()); } @@ -1942,7 +2085,7 @@ public function processStmtNode( $alwaysIterates = TrinaryLogic::createFromBoolean($context->isTopLevel()); if ($lastCondExpr !== null) { - $alwaysIterates = $alwaysIterates->and($bodyScope->getType($lastCondExpr)->toBoolean()->isTrue()); + $alwaysIterates = $alwaysIterates->and($this->readStoredOrPriceOnDemand($lastCondExpr, $bodyScope)->toBoolean()->isTrue()); $bodyScope = $this->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, $nodeCallback, ExpressionContext::createDeep())->getTruthyScope(); $bodyScope = $this->inferForLoopExpressions($stmt, $lastCondExpr, $bodyScope); } @@ -1960,7 +2103,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); @@ -2029,7 +2172,7 @@ public function processStmtNode( $hasYield = $hasYield || $caseResult->hasYield(); $throwPoints = array_merge($throwPoints, $caseResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $caseResult->getImpurePoints()); - $branchScope = $caseResult->getScope()->filterByTruthyValue($condExpr); + $branchScope = $this->narrowScopeWithCondition($caseResult->getScope(), $condExpr, TypeSpecifierContext::createTruthy()); } else { $hasDefaultCase = true; $fullCondExpr = null; @@ -2055,7 +2198,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()) { @@ -2066,7 +2209,10 @@ public function processStmtNode( } } - $exhaustive = $scopeForBranches->getType($stmt->cond) instanceof NeverType; + // $scopeForBranches is the subject narrowed by "none of the cases matched"; + // that is a genuinely different scope than the subject's own, so reprocess + // the subject there rather than re-running its result on a foreign scope. + $exhaustive = $this->processExprOnDemand($stmt->cond, $scopeForBranches, new ExpressionResultStorage())->getType() instanceof NeverType; if (!$hasDefaultCase && !$exhaustive) { $alwaysTerminating = false; @@ -2319,7 +2465,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->readStoredOrPriceOnDemand($var->var, $scope); if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { $throwPoints = array_merge($throwPoints, $this->processExprNode( $stmt, @@ -2450,7 +2596,7 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } else { $constantName = new Name\FullyQualified($const->name->toString()); } - $scope = $scope->assignExpression(new ConstFetch($constantName), $scope->getType($const->value), $scope->getNativeType($const->value)); + $scope = $scope->assignExpression(new ConstFetch($constantName), $constResult->getTypeForScope($scope), $constResult->getNativeTypeForScope($scope)); } } elseif ($stmt instanceof Node\Stmt\ClassConst) { $hasYield = false; @@ -2466,8 +2612,8 @@ public function leaveNode(Node $node): ?ExistingArrayDimFetch } $scope = $scope->assignExpression( new Expr\ClassConstFetch(new Name\FullyQualified($scope->getClassReflection()->getName()), $const->name), - $scope->getType($const->value), - $scope->getNativeType($const->value), + $constResult->getTypeForScope($scope), + $constResult->getNativeTypeForScope($scope), ); } } elseif ($stmt instanceof Node\Stmt\EnumCase) { @@ -2686,55 +2832,112 @@ 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. + */ + public function processExprOnDemand(Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage): ExpressionResult { - if (($expr instanceof MethodCall || $expr instanceof Expr\StaticCall) && $expr->name instanceof Node\Identifier) { - if (array_key_exists($expr->name->toLowerString(), $this->earlyTerminatingMethodNames)) { - if ($expr instanceof MethodCall) { - $methodCalledOnType = $scope->getType($expr->var); - } else { - if ($expr->class instanceof Name) { - $methodCalledOnType = $scope->resolveTypeByName($expr->class); - } else { - $methodCalledOnType = $scope->getType($expr->class); - } - } + $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; + } + } - foreach ($methodCalledOnType->getObjectClassNames() as $referencedClass) { - if (!$this->reflectionProvider->hasClass($referencedClass)) { - continue; - } + /** + * Reads the type, on the given scope, of a node an ExprHandler already + * processed (its ExpressionResult is in the storage of the analysis in + * progress). Used from lazily-invoked typeCallbacks instead of + * Scope::getType(): it reads the stored result rather than re-walking, and + * does not allocate a throwaway duplicate storage. Falls back to pricing the + * node as synthetic when it is not stored (e.g. a re-evaluation reached this + * before the original processing did). + */ + public function readStoredOrPriceOnDemand(Expr $expr, MutatingScope $scope): Type + { + $current = $scope->getCurrentExpressionResultStorage(); + $result = $current?->findExpressionResult($expr); + if ($result !== null) { + return $result->getTypeForScope($scope); + } - $classReflection = $this->reflectionProvider->getClass($referencedClass); - foreach (array_merge([$referencedClass], $classReflection->getParentClassesNames(), $classReflection->getNativeReflection()->getInterfaceNames()) as $className) { - if (!isset($this->earlyTerminatingMethodCalls[$className])) { - continue; - } + $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); - if (in_array((string) $expr->name, $this->earlyTerminatingMethodCalls[$className], true)) { - return $expr; - } - } - } - } - } + return $this->priceSyntheticOnDemand($expr, $scope); + } - if ($expr instanceof FuncCall && $expr->name instanceof Name) { - if (in_array((string) $expr->name, $this->earlyTerminatingFunctionCalls, true)) { - return $expr; - } + /** + * 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; } - if ($expr instanceof Expr\Exit_ || $expr instanceof Expr\Throw_) { - return $expr; - } + 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(), + )); + } - $exprType = $scope->getType($expr); - if ($exprType instanceof NeverType && $exprType->isExplicit()) { - return $expr; + /** + * Prices 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 priceSyntheticOnDemand(Expr $expr, MutatingScope $scope): Type + { + $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); + + return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getTypeForScope($scope); + } + + /** Native counterpart of readStoredOrPriceOnDemand(). */ + public function readStoredOrPriceOnDemandNative(Expr $expr, MutatingScope $scope): Type + { + $current = $scope->getCurrentExpressionResultStorage(); + $result = $current?->findExpressionResult($expr); + if ($result !== null) { + return $result->getNativeTypeForScope($scope); } - return null; + $this->guardAgainstUnprocessedRealNode($expr, __FUNCTION__); + + return $this->priceSyntheticOnDemandNative($expr, $scope); + } + + /** Native counterpart of priceSyntheticOnDemand(). */ + public function priceSyntheticOnDemandNative(Expr $expr, MutatingScope $scope): Type + { + $current = $scope->getCurrentExpressionResultStorage() ?? new ExpressionResultStorage(); + + return $this->processExprOnDemand($expr, $scope, $current->duplicate())->getNativeTypeForScope($scope); } /** @@ -2749,7 +2952,42 @@ public function processExprNode( ExpressionContext $context, ): ExpressionResult { - $this->storeBeforeScope($storage, $expr, $scope); + if ($this->returnStoredExpressionResults) { + $storedResult = $storage->findExpressionResult($expr); + if ($storedResult !== null) { + return $storedResult; + } + } + + // 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) { $newExpr = new FunctionCallableNode($expr->name, $expr); @@ -2763,7 +3001,22 @@ public function processExprNode( throw new ShouldNotHappenException(); } - return $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $newExprResult = $this->processExprNode($stmt, $newExpr, $scope, $storage, $nodeCallback, $context); + $expressionResult = $this->expressionResultFactory->create( + $newExprResult->getScope(), + beforeScope: $scope, + expr: $expr, + hasYield: $newExprResult->hasYield(), + 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: static fn () => new SpecifiedTypes(), + ); + $this->storeExpressionResult($storage, $expr, $expressionResult); + return $expressionResult; } $this->callNodeCallbackWithExpression($nodeCallback, $expr, $scope, $storage, $context); @@ -2774,23 +3027,22 @@ public function processExprNode( continue; } - return $exprHandler->processExpr($this, $stmt, $expr, $scope, $storage, $nodeCallback, $context); - } - - if ($expr instanceof List_) { - // only in assign and foreach, processed elsewhere - return new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []); + $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; } - return new ExpressionResult( - $scope, - hasYield: false, - isAlwaysTerminating: false, - throwPoints: [], - impurePoints: [], - truthyScopeCallback: static fn (): MutatingScope => $scope->filterByTruthyValue($expr), - falseyScopeCallback: static fn (): MutatingScope => $scope->filterByFalseyValue($expr), - ); + throw new ShouldNotHappenException(sprintf('Unhandled expr: %s', get_class($expr))); } /** @@ -2922,6 +3174,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); @@ -2945,7 +3228,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 { @@ -2956,7 +3239,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 { @@ -2993,10 +3276,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 { + $closureStmtsCallback = static function (Node $node, Scope $scope) use ($nodeCallback, &$executionEnds, &$gatheredReturnStatements, &$gatheredReturnStatementsWithScope, &$gatheredYieldStatements, &$gatheredYieldStatementsWithScope, &$closureScope, &$closureImpurePoints, &$invalidateExpressions): void { $nodeCallback($node, $scope); if ($scope->getAnonymousFunctionReflection() !== $closureScope->getAnonymousFunctionReflection()) { return; @@ -3022,17 +3307,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]; }; 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, @@ -3040,9 +3328,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; @@ -3083,6 +3380,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, @@ -3090,9 +3388,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); } /** @@ -3130,7 +3480,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); @@ -3143,40 +3493,115 @@ 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 new ExpressionResult($scope, false, $exprResult->isAlwaysTerminating(), $exprResult->getThrowPoints(), $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 = static function (Node $node, Scope $innerScope) use ($nodeCallback, $arrowFunctionScope, &$arrowFunctionImpurePoints, &$invalidateExpressions): void { + $nodeCallback($node, $innerScope); + 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()); + }; + + $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: static fn () => new SpecifiedTypes(),), + $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->readStoredOrPriceOnDemand($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) { @@ -3304,15 +3729,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; } @@ -3500,27 +3924,42 @@ 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; $hasYield = false; $throwPoints = []; @@ -3532,33 +3971,71 @@ 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 = []; 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] = $this->gatherClosureArgType($parametersAcceptors, $i, $arg->value, $scope); + $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArgForGather, $i, $gatheredArgTypeByIndex[$i]); + } + + $argMetadataAcceptor = $metadataAcceptor; + if ( + $metadataAcceptor !== null + && (count($parametersAcceptors) > 1 || ParametersAcceptorSelector::hasAcceptorTemplateOrLateResolvableParameterType($metadataAcceptor)) + ) { + // 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); + } + $parameters = $argMetadataAcceptor?->getParameters(); + $assignByReference = false; $parameter = null; $parameterType = null; @@ -3584,7 +4061,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(); @@ -3623,14 +4100,14 @@ 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 + $closureBindScopeFactory === null && $parameter instanceof ExtendedParameterReflection && !$arg->value->static ) { @@ -3656,7 +4133,29 @@ public function processArgs( $impurePoints = array_merge($impurePoints, $closureResult->getImpurePoints()); } - $this->storeBeforeScope($storage, $arg->value, $scopeToPass); + $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(), + ), + nativeType: $closureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: static fn () => new SpecifiedTypes(), + )); $uses = []; foreach ($arg->value->uses as $use) { @@ -3690,7 +4189,7 @@ public function processArgs( } } elseif ($arg->value instanceof Expr\ArrowFunction) { if ( - $closureBindScope === null + $closureBindScopeFactory === null && $parameter instanceof ExtendedParameterReflection && !$arg->value->static ) { @@ -3710,18 +4209,42 @@ public function processArgs( $this->callNodeCallbackWithExpression($nodeCallback, $arg->value, $scopeToPass, $storage, $context); $arrowFunctionResult = $this->processArrowFunctionNode($stmt, $arg->value, $scopeToPass, $storage, $nodeCallback, $parameterType ?? null, $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), $arrowFunctionResult->getThrowPoints())); - $impurePoints = array_merge($impurePoints, $arrowFunctionResult->getImpurePoints()); - } - $this->storeBeforeScope($storage, $arg->value, $scopeToPass); + $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(); + $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: $arrowFunctionClosureTypeResolver->buildClosureTypeForArrowFunction( + $scopeToPass, + $arg->value, + $arrowFunctionScope, + $arrowFunctionResult->getClosureTypeThrowPoints(), + $arrowFunctionResult->getClosureTypeImpurePoints(), + $arrowFunctionResult->getInvalidateExpressions(), + ), + nativeType: $arrowFunctionClosureTypeResolver->getClosureType($scopeToPass->doNotTreatPhpDocTypesAsCertain(), $arg->value), + typeCallback: null, + specifyTypesCallback: static fn () => new SpecifiedTypes(), + )); } 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->getTypeForScope($scope); $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $exprResult->isAlwaysTerminating(); @@ -3745,6 +4268,9 @@ public function processArgs( } } } + + $gatheredArgTypeByIndex[$i] = $exprResult->getTypeForScope($scope); + $this->addGatheredArgType($gatheredTypes, $gatheredUnpack, $gatheredHasName, $originalArg, $i, $gatheredArgTypeByIndex[$i]); } if ($assignByReference && $lookForUnset) { @@ -3755,7 +4281,7 @@ public function processArgs( $scope = $scope->popInFunctionCall(); } - if ($i !== 0 || $closureBindScope === null) { + if ($i !== 0 || $closureBindScopeFactory === null) { continue; } @@ -3770,14 +4296,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?->getParameters(); + 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) { @@ -3824,15 +4372,16 @@ public function processArgs( $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $argValue); } } elseif ($calleeReflection !== null && $calleeReflection->hasSideEffects()->yes()) { - $argType = $scope->getType($arg->value); + $argType = $this->readStoredOrPriceOnDemand($arg->value, $scope); 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(); } @@ -3853,7 +4402,163 @@ public function processArgs( } // not storing this, it's scope after processing all args - return new ExpressionResult($scope, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints); + return new ArgsResult( + $this->expressionResultFactory->create($scope, $scope, $callLike, $hasYield, $isAlwaysTerminating, $throwPoints, $impurePoints, + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),), + $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; + } + } + + /** + * 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->readStoredOrPriceOnDemand($e, $scope), + fn (Expr $e): Type => $this->readStoredOrPriceOnDemandNative($e, $scope), + 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()); + } } /** @@ -3990,7 +4695,9 @@ public function processVirtualAssign(MutatingScope $scope, ExpressionResultStora $assignedExpr, new VirtualAssignNodeCallback($nodeCallback), ExpressionContext::createDeep(), - static fn (MutatingScope $scope): ExpressionResult => new ExpressionResult($scope, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: []), + fn (MutatingScope $scope): ExpressionResult => $this->expressionResultFactory->create($scope, beforeScope: $scope, expr: $assignedExpr, hasYield: false, isAlwaysTerminating: false, throwPoints: [], impurePoints: [], + typeCallback: static fn () => new MixedType(), + specifyTypesCallback: static fn () => new SpecifiedTypes(),), false, ); } @@ -4058,14 +4765,14 @@ public function processStmtVarAnnotation(MutatingScope $scope, ExpressionResultS $scope = $scope->assignVariable( $name, $varTag->getType(), - $scope->getNativeType($variableNode), + $this->priceSyntheticOnDemand($variableNode, $scope->doNotTreatPhpDocTypesAsCertain()), $certainty, ); } } if (count($variableLessTags) === 1 && $defaultExpr !== null) { - $originalType = $scope->getType($defaultExpr); + $originalType = $this->readStoredOrPriceOnDemand($defaultExpr, $scope); $varTag = $variableLessTags[0]; if (!$originalType->equals($varTag->getType())) { $this->callNodeCallback($nodeCallback, new VarTagChangedExpressionTypeNode($varTag, $defaultExpr), $scope, $storage); @@ -4131,6 +4838,8 @@ private function tryProcessUnrolledConstantArrayForeach( MutatingScope $originalScope, ExpressionResultStorage $originalStorage, StatementContext $context, + Type $iterateeType, + Type $nativeIterateeType, ): ?array { if ($stmt->byRef) { @@ -4143,7 +4852,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $iterateeType = $originalScope->getType($stmt->expr); if (!$iterateeType->isConstantArray()->yes()) { return null; } @@ -4169,7 +4877,6 @@ private function tryProcessUnrolledConstantArrayForeach( return null; } - $nativeIterateeType = $originalScope->getNativeType($stmt->expr); $nativeConstantArrays = $nativeIterateeType->getConstantArrays(); $matchedNativeArrays = count($nativeConstantArrays) === count($constantArrays) ? $nativeConstantArrays : null; @@ -4305,7 +5012,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) { @@ -4330,9 +5037,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()) { @@ -4364,13 +5070,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))) @@ -4379,6 +5084,8 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $scope = $scope->enterForeach( $originalScope, $stmt->expr, + $iterateeType, + $nativeIterateeType, $stmt->valueVar->name, $keyVarName, $stmt->byRef, @@ -4393,14 +5100,17 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $storage, $stmt, $stmt->valueVar, - new GetIterableValueTypeExpr($stmt->expr), + new NativeTypeExpr( + $originalScope->getIterableValueType($iterateeType), + $originalScope->getIterableValueType($nativeIterateeType), + ), $nodeCallback, )->getScope(); $vars = $this->getAssignedVariables($stmt->valueVar); 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( @@ -4408,7 +5118,10 @@ private function enterForeach(MutatingScope $scope, ExpressionResultStorage $sto $storage, $stmt, $stmt->keyVar, - new GetIterableKeyTypeExpr($stmt->expr), + new NativeTypeExpr( + $originalScope->getIterableKeyType($iterateeType), + $originalScope->getIterableKeyType($nativeIterateeType), + ), $nodeCallback, )->getScope(); $vars = array_merge($vars, $this->getAssignedVariables($stmt->keyVar)); @@ -4471,8 +5184,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->readStoredOrPriceOnDemand($arrayArg, $scope)->getIterableValueType(), + $this->readStoredOrPriceOnDemand($arrayArg, $scope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } @@ -4689,6 +5402,11 @@ private function processNodesForTraitUse($node, ClassReflection $traitReflection throw new ShouldNotHappenException(); } $traitScope = $scope->enterTrait($traitReflection); + + // attribute args are not processed as part of the trait statements + // but rules like TraitAttributesRule ask about their types + $this->processAttributeGroups($node, $node->attrGroups, $traitScope, $storage, new NoopNodeCallback()); + $this->callNodeCallback($nodeCallback, new InTraitNode($node, $traitReflection, $scope->getClassReflection()), $traitScope, $storage); $this->processStmtNodesInternal($node, $stmts, $traitScope, $storage, $nodeCallback, StatementContext::createTopLevel()); return; @@ -4765,7 +5483,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->readStoredOrPriceOnDemand($endNode->expr, $statementResult->getScope()->toMutatingScope()); if ($exprType instanceof NeverType && $exprType->isExplicit()) { continue; } @@ -5150,12 +5868,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->readStoredOrPriceOnDemand($arrayArg, $bodyScope); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->right->getArgs()[0]->value, $lastCondExpr->left), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } @@ -5175,12 +5893,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->readStoredOrPriceOnDemand($arrayArg, $bodyScope); if ($arrayType->isList()->yes()) { $bodyScope = $bodyScope->assignExpression( new ArrayDimFetch($lastCondExpr->left->getArgs()[0]->value, $lastCondExpr->right), $arrayType->getIterableValueType(), - $bodyScope->getNativeType($arrayArg)->getIterableValueType(), + $this->readStoredOrPriceOnDemand($arrayArg, $bodyScope->doNotTreatPhpDocTypesAsCertain())->getIterableValueType(), ); } } 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/RicherScopeGetTypeHelper.php b/src/Analyser/RicherScopeGetTypeHelper.php index 132c1875809..557f4949469 100644 --- a/src/Analyser/RicherScopeGetTypeHelper.php +++ b/src/Analyser/RicherScopeGetTypeHelper.php @@ -27,7 +27,7 @@ public function __construct( /** * @return TypeResult */ - public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult + public function getIdenticalResult(Scope $scope, Identical $expr, ?NodeScopeResolver $nodeScopeResolver = null): TypeResult { if ( $expr->left instanceof Variable @@ -39,8 +39,15 @@ public function getIdenticalResult(Scope $scope, Identical $expr): TypeResult return new TypeResult(new ConstantBooleanType(true), []); } - $leftType = $scope->getType($expr->left); - $rightType = $scope->getType($expr->right); + // $nodeScopeResolver is passed from inside-out callbacks (e.g. BinaryOp's + // typeCallback) so the operands are read from their ExpressionResults + // instead of Scope::getType(); rules call this without it (BC). + $leftType = $nodeScopeResolver !== null + ? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->left, $scope->toMutatingScope()) + : $scope->getType($expr->left); + $rightType = $nodeScopeResolver !== null + ? $nodeScopeResolver->readStoredOrPriceOnDemand($expr->right, $scope->toMutatingScope()) + : $scope->getType($expr->right); if ( ( @@ -78,9 +85,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): TypeResult { - $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right)); + $identicalResult = $this->getIdenticalResult($scope, new Identical($expr->left, $expr->right), $nodeScopeResolver); $identicalType = $identicalResult->type; if ($identicalType instanceof ConstantBooleanType) { return new TypeResult(new ConstantBooleanType(!$identicalType->getValue()), $identicalResult->reasons); diff --git a/src/Analyser/SpecifiedTypes.php b/src/Analyser/SpecifiedTypes.php index 5cfa65dc53b..ad213042f35 100644 --- a/src/Analyser/SpecifiedTypes.php +++ b/src/Analyser/SpecifiedTypes.php @@ -218,13 +218,18 @@ public function unionWith(SpecifiedTypes $other): self return $result->setRootExpr($rootExpr); } - public function normalize(Scope $scope): self + public function normalize(Scope $scope, ?NodeScopeResolver $nodeScopeResolver = null): self { $sureTypes = $this->sureTypes; foreach ($this->sureNotTypes as $exprString => [$exprNode, $sureNotType]) { if (!isset($sureTypes[$exprString])) { - $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($scope->getType($exprNode), $sureNotType)]; + // $nodeScopeResolver is passed from inside-out callbacks so the expr + // type is read from its ExpressionResult instead of Scope::getType(). + $exprType = $nodeScopeResolver !== null + ? $nodeScopeResolver->readStoredOrPriceOnDemand($exprNode, $scope->toMutatingScope()) + : $scope->getType($exprNode); + $sureTypes[$exprString] = [$exprNode, TypeCombinator::remove($exprType, $sureNotType)]; continue; } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 27156a8b3f0..567beaa21f1 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -13,7 +13,6 @@ 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; @@ -95,7 +94,11 @@ public function specifyTypesInCondition( continue; } - return $exprHandler->specifyTypes($this, $scope, $expr, $context); + if ($scope instanceof MutatingScope) { + return $scope->specifyTypesOfNewWorldHandlerNode($expr, $context); + } + + break; } return $this->specifyDefaultTypes($scope, $expr, $context); @@ -511,11 +514,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; diff --git a/src/Node/CoalesceExpressionNode.php b/src/Node/CoalesceExpressionNode.php new file mode 100644 index 00000000000..ba3398f4200 --- /dev/null +++ b/src/Node/CoalesceExpressionNode.php @@ -0,0 +1,54 @@ +getAttributes()); + } + + 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/Expr/GetIterableKeyTypeExpr.php b/src/Node/Expr/GetIterableKeyTypeExpr.php deleted file mode 100644 index 60731730539..00000000000 --- a/src/Node/Expr/GetIterableKeyTypeExpr.php +++ /dev/null @@ -1,37 +0,0 @@ -expr; - } - - #[Override] - public function getType(): string - { - return 'PHPStan_Node_GetIterableKeyTypeExpr'; - } - - /** - * @return string[] - */ - #[Override] - public function getSubNodeNames(): array - { - return []; - } - -} diff --git a/src/Node/Expr/GetIterableValueTypeExpr.php b/src/Node/Expr/GetIterableValueTypeExpr.php deleted file mode 100644 index 642eb4870ea..00000000000 --- a/src/Node/Expr/GetIterableValueTypeExpr.php +++ /dev/null @@ -1,37 +0,0 @@ -expr; - } - - #[Override] - public function getType(): string - { - return 'PHPStan_Node_GetIterableValueTypeExpr'; - } - - /** - * @return string[] - */ - #[Override] - public function getSubNodeNames(): array - { - return []; - } - -} diff --git a/src/Node/Expr/GetOffsetValueTypeExpr.php b/src/Node/Expr/GetOffsetValueTypeExpr.php deleted file mode 100644 index 38226855557..00000000000 --- a/src/Node/Expr/GetOffsetValueTypeExpr.php +++ /dev/null @@ -1,42 +0,0 @@ -var; - } - - public function getDim(): Expr - { - return $this->dim; - } - - #[Override] - public function getType(): string - { - return 'PHPStan_Node_GetOffsetValueTypeExpr'; - } - - /** - * @return string[] - */ - #[Override] - public function getSubNodeNames(): array - { - return []; - } - -} diff --git a/src/Node/Expr/OriginalPropertyTypeExpr.php b/src/Node/Expr/OriginalPropertyTypeExpr.php deleted file mode 100644 index d662dbe488f..00000000000 --- a/src/Node/Expr/OriginalPropertyTypeExpr.php +++ /dev/null @@ -1,37 +0,0 @@ -propertyFetch; - } - - #[Override] - public function getType(): string - { - return 'PHPStan_Node_OriginalPropertyTypeExpr'; - } - - /** - * @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/Printer/Printer.php b/src/Node/Printer/Printer.php index 645ffb0de43..458041edfbc 100644 --- a/src/Node/Printer/Printer.php +++ b/src/Node/Printer/Printer.php @@ -13,14 +13,10 @@ use PHPStan\Node\Expr\CloneReinitializationExpr; use PHPStan\Node\Expr\ExistingArrayDimFetch; use PHPStan\Node\Expr\ForeachValueByRefExpr; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; -use PHPStan\Node\Expr\GetIterableValueTypeExpr; -use PHPStan\Node\Expr\GetOffsetValueTypeExpr; use PHPStan\Node\Expr\IntertwinedVariableByReferenceWithExpr; use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\Expr\OriginalForeachKeyExpr; use PHPStan\Node\Expr\OriginalForeachValueExpr; -use PHPStan\Node\Expr\OriginalPropertyTypeExpr; use PHPStan\Node\Expr\ParameterVariableOriginalValueExpr; use PHPStan\Node\Expr\PossiblyImpureCallExpr; use PHPStan\Node\Expr\PropertyInitializationExpr; @@ -73,36 +69,16 @@ protected function pPHPStan_Node_NativeTypeExpr(NativeTypeExpr $expr): string // return sprintf('__phpstanNativeType(%s, %s)', $expr->getPhpDocType()->describe(VerbosityLevel::precise()), $expr->getNativeType()->describe(VerbosityLevel::precise())); } - protected function pPHPStan_Node_GetOffsetValueTypeExpr(GetOffsetValueTypeExpr $expr): string // phpcs:ignore - { - return sprintf('__phpstanGetOffsetValueType(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); - } - protected function pPHPStan_Node_UnsetOffsetExpr(UnsetOffsetExpr $expr): string // phpcs:ignore { return sprintf('__phpstanUnsetOffset(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); } - protected function pPHPStan_Node_GetIterableValueTypeExpr(GetIterableValueTypeExpr $expr): string // phpcs:ignore - { - return sprintf('__phpstanGetIterableValueType(%s)', $this->p($expr->getExpr())); - } - - protected function pPHPStan_Node_GetIterableKeyTypeExpr(GetIterableKeyTypeExpr $expr): string // phpcs:ignore - { - return sprintf('__phpstanGetIterableKeyType(%s)', $this->p($expr->getExpr())); - } - protected function pPHPStan_Node_ExistingArrayDimFetch(ExistingArrayDimFetch $expr): string // phpcs:ignore { return sprintf('__phpstanExistingArrayDimFetch(%s, %s)', $this->p($expr->getVar()), $this->p($expr->getDim())); } - protected function pPHPStan_Node_OriginalPropertyTypeExpr(OriginalPropertyTypeExpr $expr): string // phpcs:ignore - { - return sprintf('__phpstanOriginalPropertyType(%s)', $this->p($expr->getPropertyFetch())); - } - protected function pPHPStan_Node_SetOffsetValueTypeExpr(SetOffsetValueTypeExpr $expr): string // phpcs:ignore { return sprintf('__phpstanSetOffsetValueType(%s, %s, %s)', $this->p($expr->getVar()), $expr->getDim() !== null ? $this->p($expr->getDim()) : 'null', $this->p($expr->getValue())); 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 d918b512f22..32fff5d98c8 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/Rules/Arrays/ArrayUnpackingRule.php b/src/Rules/Arrays/ArrayUnpackingRule.php index 4dc25092a9a..08cb915cad3 100644 --- a/src/Rules/Arrays/ArrayUnpackingRule.php +++ b/src/Rules/Arrays/ArrayUnpackingRule.php @@ -6,7 +6,7 @@ use PhpParser\Node\ArrayItem; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Php\PhpVersion; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; @@ -40,7 +40,10 @@ public function processNode(Node $node, Scope $scope): array $typeResult = $this->ruleLevelHelper->findTypeToCheck( $scope, - new GetIterableKeyTypeExpr($node->value), + new NativeTypeExpr( + $scope->getIterableKeyType($scope->getType($node->value)), + $scope->getIterableKeyType($scope->getNativeType($node->value)), + ), '', static fn (Type $type): bool => $type->isString()->no(), ); diff --git a/src/Rules/Comparison/BooleanAndConstantConditionRule.php b/src/Rules/Comparison/BooleanAndConstantConditionRule.php index 60e88caa527..2947e6d3eed 100644 --- a/src/Rules/Comparison/BooleanAndConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanAndConstantConditionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; @@ -27,6 +28,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -87,16 +89,18 @@ public function processNode( } $ruleError = $errorBuilder->build(); $hasLeftOrRightError = true; - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->left)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); } else { $errors[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); + $this->emitNoError($scope, $originalNode->left); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); + $this->emitNoError($scope, $originalNode->left); } $rightScope = $node->getRightScope(); @@ -140,16 +144,18 @@ public function processNode( } $ruleError = $errorBuilder->build(); $hasLeftOrRightError = true; - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->right)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); } else { $errors[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); + $this->emitNoError($scope, $originalNode->right); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); + $this->emitNoError($scope, $originalNode->right); } if (count($errors) === 0 && !$hasLeftOrRightError && !$scope->isInFirstLevelStatement()) { @@ -203,4 +209,16 @@ public function processNode( return $errors; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $expr); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $expr); + } + } + } diff --git a/src/Rules/Comparison/BooleanNotConstantConditionRule.php b/src/Rules/Comparison/BooleanNotConstantConditionRule.php index fe786dd3c18..0052f9f8ab4 100644 --- a/src/Rules/Comparison/BooleanNotConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanNotConstantConditionRule.php @@ -25,6 +25,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -78,6 +79,10 @@ public function processNode( $errorBuilder->identifier(sprintf('booleanNot.always%s', $exprType->getValue() ? 'False' : 'True')); $ruleError = $errorBuilder->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->expr, !$exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->expr, !$exprType->getValue(), $ruleError); return []; @@ -87,7 +92,11 @@ public function processNode( } } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->expr); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->expr); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->expr); + } return []; } diff --git a/src/Rules/Comparison/BooleanOrConstantConditionRule.php b/src/Rules/Comparison/BooleanOrConstantConditionRule.php index cc9fc93efa1..a010342caa7 100644 --- a/src/Rules/Comparison/BooleanOrConstantConditionRule.php +++ b/src/Rules/Comparison/BooleanOrConstantConditionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; @@ -27,6 +28,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -87,16 +89,18 @@ public function processNode( } $ruleError = $errorBuilder->build(); $hasLeftOrRightError = true; - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->left)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->left, $leftType->getValue(), $ruleError); } else { $messages[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); + $this->emitNoError($scope, $originalNode->left); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->left); + $this->emitNoError($scope, $originalNode->left); } $rightScope = $node->getRightScope(); @@ -140,16 +144,18 @@ public function processNode( } $ruleError = $errorBuilder->build(); $hasLeftOrRightError = true; - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->right)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->right, $rightType->getValue(), $ruleError); } else { $messages[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); + $this->emitNoError($scope, $originalNode->right); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->right); + $this->emitNoError($scope, $originalNode->right); } if (count($messages) === 0 && !$hasLeftOrRightError && !$scope->isInFirstLevelStatement()) { @@ -203,4 +209,16 @@ public function processNode( return $messages; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $expr); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $expr); + } + } + } diff --git a/src/Rules/Comparison/ConstantConditionInTraitHelper.php b/src/Rules/Comparison/ConstantConditionInTraitHelper.php index 31f70c70cc6..145c48919c1 100644 --- a/src/Rules/Comparison/ConstantConditionInTraitHelper.php +++ b/src/Rules/Comparison/ConstantConditionInTraitHelper.php @@ -27,6 +27,11 @@ public function __construct( { } + private function exprString(Expr $expr): string + { + return sprintf('%s:%d', $this->exprPrinter->printExpr($expr), $expr->getStartLine()); + } + /** * @param class-string> $ruleName */ @@ -40,11 +45,10 @@ public function emitNoError( return; } - $exprString = sprintf('%s:%d', $this->exprPrinter->printExpr($expr), $expr->getStartLine()); $scope->emitCollectedData(ConstantConditionInTraitCollector::class, [ $ruleName, $scope->getTraitReflection()->getName(), - $exprString, + $this->exprString($expr), null, ]); } @@ -68,11 +72,10 @@ public function emitError( return; } - $exprString = sprintf('%s:%d', $this->exprPrinter->printExpr($expr), $expr->getStartLine()); $scope->emitCollectedData(ConstantConditionInTraitCollector::class, [ $ruleName, $scope->getTraitReflection()->getName(), - $exprString, + $this->exprString($expr), $value, $this->ruleErrorTransformer->transform($ruleError, $scope, [], $expr), ]); diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index 30a08f665dc..5f5bb44c684 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -3,8 +3,6 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node\Expr; -use PhpParser\Node\Expr\FuncCall; -use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; @@ -15,14 +13,13 @@ final class ConstantConditionRuleHelper { public function __construct( - private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, ) { } - private function shouldSkip(Scope $scope, Expr $expr): bool + private function shouldSkip(Expr $expr): bool { if ( $expr instanceof Expr\BinaryOp\Equal @@ -50,25 +47,12 @@ private function shouldSkip(Scope $scope, Expr $expr): bool return true; } - if ( - ( - $expr instanceof FuncCall - || $expr instanceof MethodCall - || $expr instanceof Expr\StaticCall - ) && !$expr->isFirstClassCallable() - ) { - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $expr); - if ($isAlways !== null) { - return true; - } - } - return false; } public function getBooleanType(Scope $scope, Expr $expr): BooleanType { - if ($this->shouldSkip($scope, $expr)) { + if ($this->shouldSkip($expr)) { return new BooleanType(); } @@ -81,7 +65,7 @@ public function getBooleanType(Scope $scope, Expr $expr): BooleanType public function getNativeBooleanType(Scope $scope, Expr $expr): BooleanType { - if ($this->shouldSkip($scope, $expr)) { + if ($this->shouldSkip($expr)) { return new BooleanType(); } diff --git a/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php b/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php index bff27fcfe20..603d6613fba 100644 --- a/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php +++ b/src/Rules/Comparison/DoWhileLoopConstantConditionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Stmt\Break_; use PhpParser\Node\Stmt\Continue_; @@ -28,6 +29,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -44,23 +46,24 @@ public function getNodeType(): string public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataEmitter $scope): array { $exprType = $this->helper->getBooleanType($scope, $node->getCond()); + $isTypeCheckCandidate = $this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->getCond()); if ($exprType instanceof ConstantBooleanType) { if ($exprType->getValue()) { if ($node->hasYield()) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } foreach ($node->getExitPoints() as $exitPoint) { $statement = $exitPoint->getStatement(); if (!$statement instanceof Continue_) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } if (!$statement->num instanceof Int_) { continue; } if ($statement->num->value > 1) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } } @@ -68,7 +71,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE foreach ($node->getExitPoints() as $exitPoint) { $statement = $exitPoint->getStatement(); if ($statement instanceof Break_) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } } @@ -99,6 +102,10 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE ->line($node->getCond()->getStartLine()) ->identifier(sprintf('doWhile.always%s', $exprType->getValue() ? 'True' : 'False')) ->build(); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->getCond(), $exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->getCond(), $exprType->getValue(), $ruleError); return []; @@ -107,8 +114,21 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->getCond()); + $this->emitNoError($scope, $node->getCond(), $isTypeCheckCandidate); return []; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $cond, + bool $isTypeCheckCandidate, + ): void + { + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $cond); + } + } + } diff --git a/src/Rules/Comparison/ElseIfConstantConditionRule.php b/src/Rules/Comparison/ElseIfConstantConditionRule.php index 22c19dd1ec0..f4c02a62a79 100644 --- a/src/Rules/Comparison/ElseIfConstantConditionRule.php +++ b/src/Rules/Comparison/ElseIfConstantConditionRule.php @@ -25,6 +25,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -79,6 +80,10 @@ public function processNode( $errorBuilder->identifier(sprintf('elseif.always%s', $exprType->getValue() ? 'True' : 'False')); $ruleError = $errorBuilder->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); return []; @@ -88,7 +93,11 @@ public function processNode( } } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + } return []; } diff --git a/src/Rules/Comparison/FunctionCallConstantConditionCollector.php b/src/Rules/Comparison/FunctionCallConstantConditionCollector.php new file mode 100644 index 00000000000..fa37dab949c --- /dev/null +++ b/src/Rules/Comparison/FunctionCallConstantConditionCollector.php @@ -0,0 +1,28 @@ +>, trait-string|null, string, null}|array{class-string>, trait-string|null, string, bool, Error|array}> + */ +final class FunctionCallConstantConditionCollector implements Collector +{ + + public function getNodeType(): string + { + throw new ShouldNotHappenException(); + } + + public function processNode(Node $node, Scope $scope) + { + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Comparison/FunctionCallConstantConditionHelper.php b/src/Rules/Comparison/FunctionCallConstantConditionHelper.php new file mode 100644 index 00000000000..567632db258 --- /dev/null +++ b/src/Rules/Comparison/FunctionCallConstantConditionHelper.php @@ -0,0 +1,104 @@ +exprPrinter->printExpr($expr), $expr->getStartLine()); + } + + /** + * Whether the condition is a function/method/static call that the + * ImpossibleCheckType* rules might own. For these the constant-condition + * reporting is deferred to FunctionCallConstantConditionRule, which + * deduplicates against ImpossibleCheckTypeReportedCollector markers. + */ + public function isTypeCheckCandidate(Expr $expr): bool + { + return ( + $expr instanceof FuncCall + || $expr instanceof MethodCall + || $expr instanceof StaticCall + ) && !$expr->isFirstClassCallable(); + } + + /** + * @param class-string> $ruleName + */ + public function emitFunctionCallNoError( + string $ruleName, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + $scope->emitCollectedData(FunctionCallConstantConditionCollector::class, [ + $ruleName, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $this->exprString($expr), + null, + ]); + } + + /** + * @param class-string> $ruleName + */ + public function emitFunctionCallError( + string $ruleName, + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + bool $value, + RuleError $ruleError, + ): void + { + if ($ruleError instanceof FixableNodeRuleError) { + throw new ShouldNotHappenException('Fixable errors are not supported by FunctionCallConstantConditionHelper.'); + } + + $scope->emitCollectedData(FunctionCallConstantConditionCollector::class, [ + $ruleName, + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $this->exprString($expr), + $value, + $this->ruleErrorTransformer->transform($ruleError, $scope, [], $expr), + ]); + } + + public function emitImpossibleCheckReported( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + $scope->emitCollectedData(ImpossibleCheckTypeReportedCollector::class, [ + $this->exprString($expr), + ]); + } + +} diff --git a/src/Rules/Comparison/FunctionCallConstantConditionRule.php b/src/Rules/Comparison/FunctionCallConstantConditionRule.php new file mode 100644 index 00000000000..1754bd9cdc9 --- /dev/null +++ b/src/Rules/Comparison/FunctionCallConstantConditionRule.php @@ -0,0 +1,124 @@ + + */ +#[RegisteredRule(level: 4)] +final class FunctionCallConstantConditionRule implements Rule +{ + + private const NULL_TRAIT_KEY = "\0null-trait"; + + public function getNodeType(): string + { + return CollectedDataNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $reportedMarkers = []; + foreach ($node->get(ImpossibleCheckTypeReportedCollector::class) as $fileData) { + foreach ($fileData as $data) { + $reportedMarkers[$data[0]] = true; + } + } + + $errorsByRuleTraitExprValue = []; + foreach ($node->get(FunctionCallConstantConditionCollector::class) as $fileData) { + foreach ($fileData as $data) { + $ruleName = $data[0]; + $traitName = $data[1]; + $traitKey = $traitName ?? self::NULL_TRAIT_KEY; + $exprString = $data[2]; + $value = $data[3]; + $valueKey = var_export($value, true); + if ($data[3] === null) { + $errorsByRuleTraitExprValue[$ruleName][$traitKey][$exprString][$valueKey][] = null; + // no error reported + continue; + } + + $error = $data[4]; + $errorsByRuleTraitExprValue[$ruleName][$traitKey][$exprString][$valueKey][] = $error; + } + } + + $transformedErrors = []; + foreach ($errorsByRuleTraitExprValue as $ruleData) { + foreach ($ruleData as $traitKey => $traitData) { + $isTrait = $traitKey !== self::NULL_TRAIT_KEY; + foreach ($traitData as $exprString => $valueData) { + if (array_key_exists($exprString, $reportedMarkers)) { + // the ImpossibleCheckType* rule owns this call site + continue; + } + + if ($isTrait && count($valueData) > 1) { + continue; + } + + $uniquedErrors = []; + foreach ($valueData as $errors) { + foreach ($errors as $errorObject) { + if ($errorObject === null) { + continue; + } + if (is_array($errorObject)) { + $errorObject = Error::decode($errorObject); + } + + $message = $errorObject->getMessage(); + $uniquedErrors[$message] = $errorObject; + } + } + + $uniquedErrors = array_values($uniquedErrors); + if (count($uniquedErrors) === 0) { + continue; + } + + if (!$isTrait) { + foreach ($uniquedErrors as $uniquedError) { + $transformedErrors[] = new TransformedRuleError($uniquedError); + } + continue; + } + + if (count($uniquedErrors) === 1) { + // report directly in trait, no "in context of" + $transformedErrors[] = new TransformedRuleError($uniquedErrors[0]->removeTraitContext()); + continue; + } + + // report each error in its context + foreach ($uniquedErrors as $uniquedError) { + $transformedErrors[] = new TransformedRuleError($uniquedError); + } + } + } + } + + return $transformedErrors; + } + +} diff --git a/src/Rules/Comparison/IfConstantConditionRule.php b/src/Rules/Comparison/IfConstantConditionRule.php index a1eb712e653..5d43388b75f 100644 --- a/src/Rules/Comparison/IfConstantConditionRule.php +++ b/src/Rules/Comparison/IfConstantConditionRule.php @@ -24,6 +24,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -68,6 +69,10 @@ public function processNode( ))) ->identifier(sprintf('if.always%s', $exprType->getValue() ? 'True' : 'False')) ->line($node->cond->getStartLine())->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); return []; @@ -76,7 +81,11 @@ public function processNode( return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + } return []; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php index d8f6561f587..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 @@ -24,6 +25,7 @@ public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -36,70 +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 []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $funcCall); + + $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.'); @@ -109,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 f729683ecab..8ace89149f9 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -9,8 +9,8 @@ use PhpParser\Node\Expr\MethodCall; use PhpParser\Node\Expr\StaticCall; use PHPStan\Analyser\MutatingScope; +use PHPStan\Analyser\ExpressionResult; 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 279ccaf5738..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 @@ -27,6 +28,7 @@ public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -39,73 +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 = $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 []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $methodCall); + + $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, $node->name->name, $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, $node->name->name, $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.'); @@ -115,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/ImpossibleCheckTypeReportedCollector.php b/src/Rules/Comparison/ImpossibleCheckTypeReportedCollector.php new file mode 100644 index 00000000000..fb2f9a7b48b --- /dev/null +++ b/src/Rules/Comparison/ImpossibleCheckTypeReportedCollector.php @@ -0,0 +1,26 @@ + + */ +final class ImpossibleCheckTypeReportedCollector implements Collector +{ + + public function getNodeType(): string + { + throw new ShouldNotHappenException(); + } + + public function processNode(Node $node, Scope $scope) + { + throw new ShouldNotHappenException(); + } + +} diff --git a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php index bbab2d98001..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 @@ -27,6 +28,7 @@ public function __construct( private ImpossibleCheckTypeHelper $impossibleCheckTypeHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -39,74 +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 = $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 []; } - $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node, $reasons): RuleErrorBuilder { + $this->functionCallConstantConditionHelper->emitImpossibleCheckReported($scope, $staticCall); + + $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, $node->name->name, $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, $node->name->name, $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.'); @@ -116,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/Comparison/LogicalXorConstantConditionRule.php b/src/Rules/Comparison/LogicalXorConstantConditionRule.php index 16589f28886..c4566be9ea8 100644 --- a/src/Rules/Comparison/LogicalXorConstantConditionRule.php +++ b/src/Rules/Comparison/LogicalXorConstantConditionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PhpParser\Node\Expr\BinaryOp\LogicalXor; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; @@ -26,6 +27,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter] @@ -77,16 +79,18 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } $ruleError = $errorBuilder->build(); - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->left)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->left, $leftType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->left, $leftType->getValue(), $ruleError); } else { $errors[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->left); + $this->emitNoError($scope, $node->left); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->left); + $this->emitNoError($scope, $node->left); } $rightType = $this->helper->getBooleanType($scope, $node->right); @@ -124,19 +128,33 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $errorBuilder->tip('Remove remaining cases below this one and this error will disappear too.'); } $ruleError = $errorBuilder->build(); - if ($isInTrait) { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->right)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->right, $rightType->getValue(), $ruleError); + } elseif ($isInTrait) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->right, $rightType->getValue(), $ruleError); } else { $errors[] = $ruleError; } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->right); + $this->emitNoError($scope, $node->right); } } else { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->right); + $this->emitNoError($scope, $node->right); } return $errors; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $expr, + ): void + { + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($expr)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $expr); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $expr); + } + } + } diff --git a/src/Rules/Comparison/MatchExpressionRule.php b/src/Rules/Comparison/MatchExpressionRule.php index d89fe2dd87c..4757493d62f 100644 --- a/src/Rules/Comparison/MatchExpressionRule.php +++ b/src/Rules/Comparison/MatchExpressionRule.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node; +use PhpParser\Node\Expr; use PHPStan\Analyser\CollectedDataEmitter; use PHPStan\Analyser\NodeCallbackInvoker; use PHPStan\Analyser\Scope; @@ -34,6 +35,7 @@ public function __construct( private ConstantConditionRuleHelper $constantConditionRuleHelper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, ) @@ -67,14 +69,16 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE } foreach ($armConditions as $armCondition) { $armConditionScope = $armCondition->getScope(); + $rawCondition = $armCondition->getCondition(); + $isTypeCheckCandidate = $this->functionCallConstantConditionHelper->isTypeCheckCandidate($rawCondition); $armConditionExpr = new Node\Expr\BinaryOp\Identical( $matchCondition, - $armCondition->getCondition(), + $rawCondition, ); $armConditionResult = $armConditionScope->getType($armConditionExpr); if (!$armConditionResult instanceof ConstantBooleanType) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + $this->emitNoError($scope, $armConditionExpr, $rawCondition, $isTypeCheckCandidate); continue; } if ($armConditionResult->getValue()) { @@ -84,7 +88,7 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE if (!$this->treatPhpDocTypesAsCertain) { $armConditionNativeResult = $armConditionScope->getNativeType($armConditionExpr); if (!$armConditionNativeResult instanceof ConstantBooleanType) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + $this->emitNoError($scope, $armConditionExpr, $rawCondition, $isTypeCheckCandidate); continue; } if ($armConditionNativeResult->getValue()) { @@ -93,9 +97,9 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE } if ($matchConditionType instanceof ConstantBooleanType) { - $armConditionStandaloneResult = $this->constantConditionRuleHelper->getBooleanType($armConditionScope, $armCondition->getCondition()); + $armConditionStandaloneResult = $this->constantConditionRuleHelper->getBooleanType($armConditionScope, $rawCondition); if (!$armConditionStandaloneResult instanceof ConstantBooleanType) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + $this->emitNoError($scope, $armConditionExpr, $rawCondition, $isTypeCheckCandidate); continue; } } @@ -105,11 +109,15 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE $errorBuilder = RuleErrorBuilder::message(sprintf( 'Match arm comparison between %s and %s is always false.', $armConditionScope->getType($matchCondition)->describe(VerbosityLevel::value()), - $armConditionScope->getType($armCondition->getCondition())->describe(VerbosityLevel::value()), + $armConditionScope->getType($rawCondition)->describe(VerbosityLevel::value()), ))->line($armLine)->identifier('match.alwaysFalse'); $this->possiblyImpureTipHelper->addTip($armConditionScope, $armConditionExpr, $errorBuilder); $ruleError = $errorBuilder->build(); - if ($scope->isInTrait()) { + if ($isTypeCheckCandidate) { + // the constant-ness of a type-check call is owned by the + // ImpossibleCheckType* rules; defer and deduplicate against them + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $rawCondition, false, $ruleError); + } elseif ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $armConditionExpr, false, $ruleError); } else { $errors[] = $ruleError; @@ -118,14 +126,14 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE } if ($i === $armsCount - 1) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + $this->emitNoError($scope, $armConditionExpr, $rawCondition, $isTypeCheckCandidate); continue; } $message = sprintf( 'Match arm comparison between %s and %s is always true.', $armConditionScope->getType($matchCondition)->describe(VerbosityLevel::value()), - $armConditionScope->getType($armCondition->getCondition())->describe(VerbosityLevel::value()), + $armConditionScope->getType($rawCondition)->describe(VerbosityLevel::value()), ); $errorBuilder = RuleErrorBuilder::message($message) ->line($armLine) @@ -133,7 +141,9 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE ->tip('Remove remaining cases below this one and this error will disappear too.'); $this->possiblyImpureTipHelper->addTip($armConditionScope, $armConditionExpr, $errorBuilder); $ruleError = $errorBuilder->build(); - if ($scope->isInTrait()) { + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $rawCondition, true, $ruleError); + } elseif ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $armConditionExpr, true, $ruleError); } else { $errors[] = $ruleError; @@ -167,6 +177,20 @@ public function processNode(Node $node, Scope&NodeCallbackInvoker&CollectedDataE return $errors; } + private function emitNoError( + Scope&NodeCallbackInvoker&CollectedDataEmitter $scope, + Expr $armConditionExpr, + Expr $rawCondition, + bool $isTypeCheckCandidate, + ): void + { + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $rawCondition); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $armConditionExpr); + } + } + private function isUnhandledMatchErrorCaught(Node $node): bool { $tryCatchTypes = $node->getAttribute(TryCatchTypeVisitor::ATTRIBUTE_NAME); diff --git a/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php b/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php index ddb606965c9..d1732bcb416 100644 --- a/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php +++ b/src/Rules/Comparison/TernaryOperatorConstantConditionRule.php @@ -24,6 +24,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -65,6 +66,10 @@ public function processNode( 'Ternary operator condition is always %s.', $exprType->getValue() ? 'true' : 'false', )))->identifier(sprintf('ternary.always%s', $exprType->getValue() ? 'True' : 'False'))->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, $exprType->getValue(), $ruleError); return []; @@ -73,7 +78,11 @@ public function processNode( return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + } return []; } diff --git a/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php b/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php index d6bd7479dc8..4859e0270cb 100644 --- a/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php +++ b/src/Rules/Comparison/WhileLoopAlwaysFalseConditionRule.php @@ -24,6 +24,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -65,6 +66,10 @@ public function processNode( $ruleError = $addTip(RuleErrorBuilder::message('While loop condition is always false.'))->line($node->cond->getStartLine()) ->identifier('while.alwaysFalse') ->build(); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $node->cond, false, $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $node->cond, false, $ruleError); return []; @@ -73,7 +78,11 @@ public function processNode( return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + if ($this->functionCallConstantConditionHelper->isTypeCheckCandidate($node->cond)) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $node->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $node->cond); + } return []; } diff --git a/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php b/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php index ef942cfe0cf..2acdc51da6b 100644 --- a/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php +++ b/src/Rules/Comparison/WhileLoopAlwaysTrueConditionRule.php @@ -28,6 +28,7 @@ public function __construct( private ConstantConditionRuleHelper $helper, private PossiblyImpureTipHelper $possiblyImpureTipHelper, private ConstantConditionInTraitHelper $constantConditionInTraitHelper, + private FunctionCallConstantConditionHelper $functionCallConstantConditionHelper, #[AutowiredParameter] private bool $treatPhpDocTypesAsCertain, #[AutowiredParameter(ref: '%tips.treatPhpDocTypesAsCertain%')] @@ -71,16 +72,25 @@ public function processNode( } $originalNode = $node->getOriginalNode(); $exprType = $this->helper->getBooleanType($scope, $originalNode->cond); + $isTypeCheckCandidate = $this->functionCallConstantConditionHelper->isTypeCheckCandidate($originalNode->cond); if ($exprType->isTrue()->yes()) { if ($node->hasYield()) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $originalNode->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + } return []; } $ref = $scope->getFunction() ?? $scope->getAnonymousFunctionReflection(); if ($ref !== null && $ref->getReturnType() instanceof NeverType) { - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $originalNode->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + } return []; } @@ -105,6 +115,10 @@ public function processNode( $ruleError = $addTip(RuleErrorBuilder::message('While loop condition is always true.'))->line($originalNode->cond->getStartLine()) ->identifier('while.alwaysTrue') ->build(); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallError(self::class, $scope, $originalNode->cond, true, $ruleError); + return []; + } if ($scope->isInTrait()) { $this->constantConditionInTraitHelper->emitError(self::class, $scope, $originalNode->cond, true, $ruleError); return []; @@ -113,7 +127,11 @@ public function processNode( return [$ruleError]; } - $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + if ($isTypeCheckCandidate) { + $this->functionCallConstantConditionHelper->emitFunctionCallNoError(self::class, $scope, $originalNode->cond); + } else { + $this->constantConditionInTraitHelper->emitNoError(self::class, $scope, $originalNode->cond); + } return []; } diff --git a/src/Rules/IssetCheck.php b/src/Rules/IssetCheck.php index 35195e7ea52..eff4ed98840 100644 --- a/src/Rules/IssetCheck.php +++ b/src/Rules/IssetCheck.php @@ -2,22 +2,27 @@ 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 sprintf; use function str_starts_with; /** + * 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 +31,6 @@ final class IssetCheck public function __construct( private PropertyDescriptor $propertyDescriptor, - private PropertyReflectionFinder $propertyReflectionFinder, #[AutowiredParameter] private bool $checkAdvancedIsset, #[AutowiredParameter] @@ -39,26 +43,40 @@ 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 ($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 +84,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 +117,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 +130,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 +168,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 +214,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 +224,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 +240,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/PhpDoc/VarTagTypeRuleHelper.php b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php index 4a6e8421d91..127501aa58c 100644 --- a/src/Rules/PhpDoc/VarTagTypeRuleHelper.php +++ b/src/Rules/PhpDoc/VarTagTypeRuleHelper.php @@ -8,7 +8,7 @@ use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Node\Expr\GetOffsetValueTypeExpr; +use PHPStan\Node\Expr\TypeExpr; use PHPStan\PhpDoc\NameScopeAlreadyBeingCreatedException; use PHPStan\PhpDoc\Tag\VarTag; use PHPStan\PhpDoc\TypeNodeResolver; @@ -78,7 +78,7 @@ public function checkVarType(Scope $scope, Node\Expr $var, Node\Expr $expr, arra $dimExpr = $arrayItem->key; } - $itemErrors = $this->checkVarType($scope, $arrayItem->value, new GetOffsetValueTypeExpr($expr, $dimExpr), $varTags, $assignedVariables); + $itemErrors = $this->checkVarType($scope, $arrayItem->value, new TypeExpr($scope->getType($expr)->getOffsetValueType($scope->getType($dimExpr))), $varTags, $assignedVariables); foreach ($itemErrors as $error) { $errors[] = $error; } diff --git a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php index 6296e12ca6c..fbd3f1c7bb2 100644 --- a/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php +++ b/src/Rules/PhpDoc/WrongVariableNameInVarTagRule.php @@ -7,8 +7,7 @@ use PhpParser\Node\Expr; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; -use PHPStan\Node\Expr\GetIterableKeyTypeExpr; -use PHPStan\Node\Expr\GetIterableValueTypeExpr; +use PHPStan\Node\Expr\NativeTypeExpr; use PHPStan\Node\InClassMethodNode; use PHPStan\Node\InClassNode; use PHPStan\Node\InFunctionNode; @@ -271,11 +270,19 @@ private function processForeach(Scope $scope, Node\Expr $iterateeExpr, ?Node\Exp $errors[] = $error; } if ($keyVar !== null) { - foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $keyVar, new GetIterableKeyTypeExpr($iterateeExpr), $varTags, $variableNames) as $error) { + $keyTypeExpr = new NativeTypeExpr( + $scope->getIterableKeyType($scope->getScopeType($iterateeExpr)), + $scope->getIterableKeyType($scope->getScopeNativeType($iterateeExpr)), + ); + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $keyVar, $keyTypeExpr, $varTags, $variableNames) as $error) { $errors[] = $error; } } - foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $valueVar, new GetIterableValueTypeExpr($iterateeExpr), $varTags, $variableNames) as $error) { + $valueTypeExpr = new NativeTypeExpr( + $scope->getIterableValueType($scope->getScopeType($iterateeExpr)), + $scope->getIterableValueType($scope->getScopeNativeType($iterateeExpr)), + ); + foreach ($this->varTagTypeRuleHelper->checkVarType($scope, $valueVar, $valueTypeExpr, $varTags, $variableNames) as $error) { $errors[] = $error; } 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/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 79941d0293d..f0365d59216 100644 --- a/src/Rules/Variables/NullCoalesceRule.php +++ b/src/Rules/Variables/NullCoalesceRule.php @@ -5,12 +5,13 @@ use PhpParser\Node; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\RegisteredRule; +use PHPStan\Node\CoalesceExpressionNode; use PHPStan\Rules\IssetCheck; use PHPStan\Rules\Rule; use PHPStan\Type\Type; /** - * @implements Rule + * @implements Rule */ #[RegisteredRule(level: 1)] final class NullCoalesceRule implements Rule @@ -22,31 +23,29 @@ public function __construct(private IssetCheck $issetCheck) 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) { - $error = $this->issetCheck->check($node->left, $scope, 'on left side of ??', 'nullCoalesce', $typeMessageCallback); - } elseif ($node instanceof Node\Expr\AssignOp\Coalesce) { - $error = $this->issetCheck->check($node->var, $scope, 'on left side of ??=', 'nullCoalesce', $typeMessageCallback); - } 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'; + }, + ); if ($error === null) { return []; diff --git a/src/Testing/RuleTestCase.php b/src/Testing/RuleTestCase.php index 7f137660159..5155b8496e1 100644 --- a/src/Testing/RuleTestCase.php +++ b/src/Testing/RuleTestCase.php @@ -6,6 +6,7 @@ use PHPStan\Analyser\Analyser; use PHPStan\Analyser\AnalyserResultFinalizer; use PHPStan\Analyser\Error; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\FileAnalyser; @@ -105,7 +106,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getByType(PhpDocInheritanceResolver::class), self::getContainer()->getByType(FileHelper::class), - $typeSpecifier, $readWritePropertiesExtensions !== [] ? new DirectReadWritePropertiesExtensionProvider($readWritePropertiesExtensions) : self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), self::getContainer()->getByType(ParameterClosureThisExtensionProvider::class), self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), @@ -114,11 +114,10 @@ protected function createNodeScopeResolver(): NodeScopeResolver $this->shouldPolluteScopeWithLoopInitialAssignments(), $this->shouldPolluteScopeWithAlwaysIterableForeach(), self::getContainer()->getParameter('polluteScopeWithBlock'), - [], - [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ImplicitToStringCallHelper::class), + self::getContainer()->getByType(ExpressionResultFactory::class), ); } diff --git a/src/Testing/TypeInferenceTestCase.php b/src/Testing/TypeInferenceTestCase.php index 7723560fd06..75fe0b31384 100644 --- a/src/Testing/TypeInferenceTestCase.php +++ b/src/Testing/TypeInferenceTestCase.php @@ -6,6 +6,7 @@ use PhpParser\Node; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Name; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\Fiber\FiberNodeScopeResolver; use PHPStan\Analyser\MutatingScope; @@ -79,7 +80,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getByType(FileTypeMapper::class), $container->getByType(PhpDocInheritanceResolver::class), $container->getByType(FileHelper::class), - $typeSpecifier, $container->getByType(ReadWritePropertiesExtensionProvider::class), $container->getByType(ParameterClosureThisExtensionProvider::class), $container->getByType(ParameterClosureTypeExtensionProvider::class), @@ -88,11 +88,10 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('polluteScopeWithLoopInitialAssignments'), $container->getParameter('polluteScopeWithAlwaysIterableForeach'), $container->getParameter('polluteScopeWithBlock'), - static::getEarlyTerminatingMethodCalls(), - static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ImplicitToStringCallHelper::class), + $container->getByType(ExpressionResultFactory::class), ); } @@ -486,16 +485,4 @@ protected static function getAdditionalAnalysedFiles(): array return []; } - /** @return string[][] */ - protected static function getEarlyTerminatingMethodCalls(): array - { - return []; - } - - /** @return string[] */ - protected static function getEarlyTerminatingFunctionCalls(): array - { - return []; - } - } 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/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..7df7c262388 100644 --- a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php @@ -18,11 +18,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 +31,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 +51,7 @@ public function getTypeFromFunctionCall( return null; } - $isAlways = $this->getHelper()->findSpecifiedType( + $isAlways = $this->getHelper()->findSpecifiedTypeFromScope( $scope, $functionCall, ); @@ -71,7 +64,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 6f781f28a25..bd696ce7f7f 100644 --- a/tests/PHPStan/Analyser/AnalyserTest.php +++ b/tests/PHPStan/Analyser/AnalyserTest.php @@ -821,7 +821,6 @@ private function createAnalyser(): Analyser $fileTypeMapper, $phpDocInheritanceResolver, $fileHelper, - $typeSpecifier, $container->getByType(ReadWritePropertiesExtensionProvider::class), $container->getByType(ParameterClosureThisExtensionProvider::class), $container->getByType(ParameterClosureTypeExtensionProvider::class), @@ -830,11 +829,10 @@ private function createAnalyser(): Analyser false, true, true, - [], - [], true, $this->shouldTreatPhpDocTypesAsCertain(), $container->getByType(ImplicitToStringCallHelper::class), + $container->getByType(ExpressionResultFactory::class), ); $lexer = new Lexer(); $fileAnalyser = new FileAnalyser( diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php index 0a1804341a6..5f073066d2d 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverRuleTest.php @@ -3,6 +3,7 @@ namespace PHPStan\Analyser\Fiber; use PhpParser\Node; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -124,7 +125,6 @@ protected function createNodeScopeResolver(): NodeScopeResolver self::getContainer()->getByType(FileTypeMapper::class), self::getContainer()->getByType(PhpDocInheritanceResolver::class), self::getContainer()->getByType(FileHelper::class), - $typeSpecifier, $readWritePropertiesExtensions !== [] ? new DirectReadWritePropertiesExtensionProvider($readWritePropertiesExtensions) : self::getContainer()->getByType(ReadWritePropertiesExtensionProvider::class), self::getContainer()->getByType(ParameterClosureThisExtensionProvider::class), self::getContainer()->getByType(ParameterClosureTypeExtensionProvider::class), @@ -133,11 +133,10 @@ protected function createNodeScopeResolver(): NodeScopeResolver $this->shouldPolluteScopeWithLoopInitialAssignments(), $this->shouldPolluteScopeWithAlwaysIterableForeach(), self::getContainer()->getParameter('polluteScopeWithBlock'), - [], - [], self::getContainer()->getParameter('exceptions')['implicitThrows'], $this->shouldTreatPhpDocTypesAsCertain(), self::getContainer()->getByType(ImplicitToStringCallHelper::class), + self::getContainer()->getByType(ExpressionResultFactory::class), ); } diff --git a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php index ae9aa1ec4c1..592e8e10ea9 100644 --- a/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/Fiber/FiberNodeScopeResolverTest.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\Fiber; +use PHPStan\Analyser\ExpressionResultFactory; use PHPStan\Analyser\ExprHandler\Helper\ImplicitToStringCallHelper; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\DependencyInjection\Type\ParameterClosureThisExtensionProvider; @@ -57,7 +58,6 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getByType(FileTypeMapper::class), $container->getByType(PhpDocInheritanceResolver::class), $container->getByType(FileHelper::class), - $typeSpecifier, $container->getByType(ReadWritePropertiesExtensionProvider::class), $container->getByType(ParameterClosureThisExtensionProvider::class), $container->getByType(ParameterClosureTypeExtensionProvider::class), @@ -66,11 +66,10 @@ protected static function createNodeScopeResolver(): NodeScopeResolver $container->getParameter('polluteScopeWithLoopInitialAssignments'), $container->getParameter('polluteScopeWithAlwaysIterableForeach'), $container->getParameter('polluteScopeWithBlock'), - static::getEarlyTerminatingMethodCalls(), - static::getEarlyTerminatingFunctionCalls(), $container->getParameter('exceptions')['implicitThrows'], $container->getParameter('treatPhpDocTypesAsCertain'), $container->getByType(ImplicitToStringCallHelper::class), + $container->getByType(ExpressionResultFactory::class), ); } diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index fc474ac68db..cdd9a94467f 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -46,6 +46,7 @@ public static function getAdditionalConfigFiles(): array return [ __DIR__ . '/../../../conf/bleedingEdge.neon', __DIR__ . '/typeAliases.neon', + __DIR__ . '/nodeScopeResolverEarlyTerminating.neon', ]; } @@ -92,19 +93,4 @@ public function testEarlyTermination(): void }); } - protected static function getEarlyTerminatingMethodCalls(): array - { - return [ - \EarlyTermination\Foo::class => [ - 'doFoo', - 'doBar', - ], - ]; - } - - protected static function getEarlyTerminatingFunctionCalls(): array - { - return ['baz']; - } - } diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index fa3a4f64d41..90d47a9efd8 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -352,6 +352,7 @@ public static function getAdditionalConfigFiles(): array [ __DIR__ . '/../../../conf/bleedingEdge.neon', __DIR__ . '/typeAliases.neon', + __DIR__ . '/nodeScopeResolverEarlyTerminating.neon', ], ); } @@ -366,19 +367,4 @@ protected static function getAdditionalAnalysedFiles(): array ]; } - protected static function getEarlyTerminatingMethodCalls(): array - { - return [ - \EarlyTermination\Foo::class => [ - 'doFoo', - 'doBar', - ], - ]; - } - - protected static function getEarlyTerminatingFunctionCalls(): array - { - return ['baz']; - } - } diff --git a/tests/PHPStan/Analyser/data/bug-10302.php b/tests/PHPStan/Analyser/data/bug-10302.php index 9cb45b51b6a..29389436993 100644 --- a/tests/PHPStan/Analyser/data/bug-10302.php +++ b/tests/PHPStan/Analyser/data/bug-10302.php @@ -51,5 +51,5 @@ function (BatchAwareWithoutAllowDynamicProperties $b): void { $result = $b->busy; // @phpstan-ignore-line - assertType('*ERROR*', $result); + assertType('mixed', $result); }; diff --git a/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon b/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon new file mode 100644 index 00000000000..90badb85150 --- /dev/null +++ b/tests/PHPStan/Analyser/nodeScopeResolverEarlyTerminating.neon @@ -0,0 +1,7 @@ +parameters: + earlyTerminatingMethodCalls: + EarlyTermination\Foo: + - doFoo + - doBar + earlyTerminatingFunctionCalls: + - baz diff --git a/tests/PHPStan/Analyser/nsrt/array-map.php b/tests/PHPStan/Analyser/nsrt/array-map.php index 5acfd1ab7a4..fe86a874bf6 100644 --- a/tests/PHPStan/Analyser/nsrt/array-map.php +++ b/tests/PHPStan/Analyser/nsrt/array-map.php @@ -94,8 +94,10 @@ public function doFoo(): void assertType("array{'0', '1'}", array_map('strval', $a)); assertType("array{'0', '1'}", array_map(strval(...), $a)); - assertType("array{'0', '1'}", array_map(fn ($v) => strval($v), $a)); - assertType("array{'0', '1'}", array_map(fn ($v) => (string)$v, $a)); + // per-constant-array-item closure reanalysis is not done, so the closure + // body is resolved once over the unioned element type rather than per item. + assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => strval($v), $a)); + assertType("array{'0'|'1', '0'|'1'}", array_map(fn ($v) => (string)$v, $a)); } public function doFizzBuzz(): void diff --git a/tests/PHPStan/Analyser/nsrt/array-merge2.php b/tests/PHPStan/Analyser/nsrt/array-merge2.php index f0d86e61b6a..0ca400fec81 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge2.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge2.php @@ -22,7 +22,7 @@ public function arrayMergeArrayShapes($array1, $array2): void assertType("array{foo: 3, bar: '2', lall2: '3', 0: '4', 1: '6', lall: '3', 2: '2', 3: '3'}", array_merge($array2, $array1, ['foo' => 3])); assertType("array{foo: 3, bar: '2', lall2: '3', 0: '4', 1: '6', lall: '3', 2: '2', 3: '3'}", array_merge($array2, $array1, ...[['foo' => 3]])); assertType("array{foo: '1', bar: '2'|'4', lall?: '3', 0: '2'|'4', 1: '3'|'6', lall2?: '3'}", array_merge(rand(0, 1) ? $array1 : $array2, [])); - assertType("array{foo?: 3, bar?: 3}", array_merge([], ...[rand(0, 1) ? ['foo' => 3] : ['bar' => 3]])); + assertType("array{bar?: 3, foo?: 3}", array_merge([], ...[rand(0, 1) ? ['foo' => 3] : ['bar' => 3]])); assertType("array{foo: '1', bar: '2'|'4', lall?: '3', 0: '2'|'4', 1: '3'|'6', lall2?: '3'}", array_merge([], ...[rand(0, 1) ? $array1 : $array2])); assertType("array{foo: 1, bar: 2, 0: 2, 1: 3}", array_merge(['foo' => 4, 'bar' => 5], ...[['foo' => 1, 'bar' => 2], [2, 3]])); } 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 @@ +x); assertType('string', $this->y); - assertType('*ERROR*', $this->z); + assertType('mixed', $this->z); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-10786.php b/tests/PHPStan/Analyser/nsrt/bug-10786.php new file mode 100644 index 00000000000..4dc9333aba6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10786.php @@ -0,0 +1,23 @@ +value) && is_null($b->value)) { + throw new \Exception(); + } + + assertType('int', $a->value ?? $b->value); + + return $a->value ?? $b->value; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-11014.php b/tests/PHPStan/Analyser/nsrt/bug-11014.php index 1af154eeb61..5ab8b840d27 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11014.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11014.php @@ -81,7 +81,7 @@ public function constantArrayFilter(): void ], static function ($function_name) { assertType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name); - assertNativeType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name); + assertNativeType('mixed', $function_name); return true; }, @@ -93,7 +93,7 @@ public function constantArrayMap(): void array_map( static function ($function_name) { assertType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name); - assertNativeType("'curl_multi_add_handle'|'curl_multi_exec'|'curl_multi_init'", $function_name); + assertNativeType('mixed', $function_name); return true; }, diff --git a/tests/PHPStan/Analyser/nsrt/bug-11953.php b/tests/PHPStan/Analyser/nsrt/bug-11953.php new file mode 100644 index 00000000000..af3f138d743 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-11953.php @@ -0,0 +1,23 @@ + $this->id, + $foo, + Foo::class, +); + +assertType('((Closure(): int)|null)', $closure); diff --git a/tests/PHPStan/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-13253.php b/tests/PHPStan/Analyser/nsrt/bug-13253.php new file mode 100644 index 00000000000..5bc8ac64c39 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13253.php @@ -0,0 +1,80 @@ += 7.4 + +declare(strict_types = 1); + +namespace Bug13253; + +use Generator; +use ReflectionFunction; +use function PHPStan\Testing\assertType; + +/** + * @template TKey + * @template TValue + */ +class Pfline +{ + + /** @var iterable */ + private iterable $data; + + /** @param iterable $data */ + public function __construct(iterable $data) + { + $this->data = $data; + } + + /** + * @return iterable + */ + public function data(): iterable + { + return $this->data; + } + + /** + * @template TMapKey + * @template TMapValue + * @param null|callable(TValue): Generator $func + * @phpstan-self-out self + * @return self + */ + public function map(?callable $func = null): self + { + return $this; + } + +} + +function (): void { + $reflection = new ReflectionFunction('strlen'); + $params = $reflection->getParameters(); + + // Without chaining (each call advances the type through @phpstan-self-out) + $pipeline = new Pfline($params); + $pipeline->map(function ($param) { + assertType('ReflectionParameter', $param); + yield $param; + }); + $pipeline->map(function ($param) { + assertType('ReflectionParameter', $param); + yield $param->getName(); + }); + $pipeline->map(function ($param) { + assertType('non-empty-string', $param); + yield substr_count('.', $param); + }); + + // With chaining (the type must advance through @return self) + $pipeline = new Pfline($params); + $pipeline->map(function ($param) { + assertType('ReflectionParameter', $param); + yield $param; + })->map(function ($param) { + assertType('ReflectionParameter', $param); + yield $param->getName(); + })->map(function ($param) { + assertType('non-empty-string', $param); + yield substr_count('.', $param); + }); +}; 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-14649.php b/tests/PHPStan/Analyser/nsrt/bug-14649.php index f1e31730a83..de5a272c2af 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14649.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14649.php @@ -19,11 +19,13 @@ public function isGreaterThanOrEqual(Role $role): bool self::cases() ); - assertType("array{'OWNER', 'ADMIN', 'EDITOR'}", $map); + // per-constant-array-item closure reanalysis is not done, so the property + // fetch is resolved once over the unioned element type rather than per item. + assertType("array{'ADMIN'|'EDITOR'|'OWNER', 'ADMIN'|'EDITOR'|'OWNER', 'ADMIN'|'EDITOR'|'OWNER'}", $map); $hierarchy = array_flip($map); - assertType("array{OWNER: 0, ADMIN: 1, EDITOR: 2}", $hierarchy); + assertType("non-empty-array{ADMIN?: 0|1|2, EDITOR?: 0|1|2, OWNER?: 0|1|2}", $hierarchy); return $hierarchy[$this->value] <= $hierarchy[$role->value]; } @@ -69,12 +71,16 @@ function testIntBackedEnum(): void static fn (IntEnum $e): int => $e->value, IntEnum::cases() ); - assertType("array{10, 20}", $result); + // per-constant-array-item closure reanalysis is not done, so the property + // fetch is resolved once over the unioned element type rather than per item. + assertType("array{10|20, 10|20}", $result); } function testClosureWithStringKeys(): void { $arr = ['x' => 1, 'y' => 2]; $result = array_map(fn(int $v): string => (string)$v, $arr); - assertType("array{x: '1', y: '2'}", $result); + // per-constant-array-item closure reanalysis is not done, so the cast is + // resolved once over the unioned element type rather than per item. + assertType("array{x: '1'|'2', y: '1'|'2'}", $result); } 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/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 @@ +objectRelative); assertType('SomeOtherNamespace\Ipsum', $this->objectFullyQualified); assertType('SomeNamespace\Amet', $this->objectUsed); - assertType('*ERROR*', $this->nonexistentProperty); + assertType('mixed', $this->nonexistentProperty); assertType('int|null', $this->nullableInteger); assertType('SomeNamespace\Amet|null', $this->nullableObject); assertType('PropertiesNamespace\Foo', $this->selfType); diff --git a/tests/PHPStan/Analyser/nsrt/type-aliases.php b/tests/PHPStan/Analyser/nsrt/type-aliases.php index dd7440470f2..978d20a9fbc 100644 --- a/tests/PHPStan/Analyser/nsrt/type-aliases.php +++ b/tests/PHPStan/Analyser/nsrt/type-aliases.php @@ -36,8 +36,8 @@ class Baz public function circularAlias() { - assertType('*ERROR*', $this->baz); - assertType('*ERROR*', $this->qux); + assertType('mixed', $this->baz); + assertType('mixed', $this->qux); } } diff --git a/tests/PHPStan/Analyser/nsrt/union-intersection.php b/tests/PHPStan/Analyser/nsrt/union-intersection.php index 6db3b83bd3a..a531111f849 100644 --- a/tests/PHPStan/Analyser/nsrt/union-intersection.php +++ b/tests/PHPStan/Analyser/nsrt/union-intersection.php @@ -113,7 +113,7 @@ public function doFoo(WithFoo $foo, WithFoo $foobar, object $object) assertType('UnionIntersection\AnotherFoo|UnionIntersection\Foo', $this->union->foo); assertType('UnionIntersection\Bar', $this->union->bar); assertType('UnionIntersection\Foo', $foo->foo); - assertType('*ERROR*', $foo->bar); + assertType('mixed', $foo->bar); assertType('UnionIntersection\AnotherFoo|UnionIntersection\Foo', $this->union->doFoo()); assertType('UnionIntersection\Bar', $this->union->doBar()); assertType('UnionIntersection\Foo', $foo->doFoo()); diff --git a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php index fea58e7d61d..81e2aea0a72 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanAndConstantConditionRuleTest.php @@ -24,19 +24,52 @@ protected function getRule(): Rule return new CompositeRule([ new BooleanAndConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } @@ -354,6 +387,11 @@ public static function dataBug4969(): iterable { yield [false, []]; yield [true, [ + [ + 'Call to function is_string() with string will always evaluate to true.', + 12, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], [ 'Result of && is always false.', 15, diff --git a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php index e2121b4b7fc..438b542441f 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php @@ -23,19 +23,52 @@ protected function getRule(): Rule return new CompositeRule([ new BooleanNotConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php index ba4aab80505..84fd1c59dc3 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php @@ -24,19 +24,52 @@ protected function getRule(): Rule return new CompositeRule([ new BooleanOrConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php index 6a6269ae7c0..ce4efddafd7 100644 --- a/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/DoWhileLoopConstantConditionRuleTest.php @@ -19,18 +19,51 @@ protected function getRule(): Rule return new CompositeRule([ new DoWhileLoopConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->shouldTreatPhpDocTypesAsCertain(), - ), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php index 2683e22f403..8f706ac679f 100644 --- a/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ElseIfConstantConditionRuleTest.php @@ -24,19 +24,52 @@ protected function getRule(): Rule return new CompositeRule([ new ElseIfConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + $this->reportAlwaysTrueInLastCondition, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php index 37a532cb07c..72047361970 100644 --- a/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php @@ -21,18 +21,51 @@ protected function getRule(): Rule return new CompositeRule([ new IfConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } @@ -55,6 +88,10 @@ public function testRule(): void 'If condition is always false.', 45, ], + [ + 'Call to function is_object() with object will always evaluate to true.', + 93, + ], [ 'If condition is always true.', 96, @@ -114,6 +151,11 @@ public function testBug4043(): void { $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-4043.php'], [ + [ + 'Call to function assert() with true will always evaluate to true.', + 13, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], [ 'If condition is always false.', 43, @@ -257,6 +299,22 @@ public function testBug6211(): void { $this->treatPhpDocTypesAsCertain = true; $this->analyse([__DIR__ . '/data/bug-6211.php'], [ + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 34, + ], + [ + 'Call to function method_exists() with \'Bug6211\\\\Hell\' and \'test\' will always evaluate to true.', + 39, + ], + [ + 'Call to function method_exists() with Bug6211\Bar and \'realMethod\' will always evaluate to true.', + 62, + ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 87, + ], [ 'If condition is always true.', 93, @@ -265,10 +323,31 @@ public function testBug6211(): void 'If condition is always true.', 100, ], + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 106, + ], + [ + 'Call to function method_exists() with Bug6211\Hell and \'test\' will always evaluate to true.', + 107, + ], [ 'If condition is always true.', 114, ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 120, + ], + [ + 'Call to function property_exists() with Bug6211\Baz and \'realProp\' will always evaluate to true.', + 121, + ], + [ + 'Call to function method_exists() with class-string and \'test\' will always evaluate to true.', + 136, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], ]); } @@ -289,4 +368,21 @@ public function testBug8980(): void ]); } + public function testConstantConditionFunctionCall(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/constant-condition-function-call.php'], [ + [ + // type-check call: owned by the ImpossibleCheckType rule, reported only once + 'Call to function is_int() with int will always evaluate to true.', + 13, + ], + [ + // non-type-check, always-truthy return: reported by the constant-condition rule + 'If condition is always true.', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 135fa844be8..0f9f2cc3340 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -29,11 +29,11 @@ protected function getRule(): Rule new ImpossibleCheckTypeFunctionCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php index fae1bdb6e11..9e7ed77f674 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeGenericOverwriteRuleTest.php @@ -16,11 +16,11 @@ public function getRule(): Rule return new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), true, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), true, false, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php index 6d7e5d9f2ed..5252c264e67 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleEqualsTest.php @@ -16,11 +16,11 @@ public function getRule(): Rule return new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), true, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), true, false, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php index 9dde818f42c..4a11f703d65 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeMethodCallRuleTest.php @@ -25,11 +25,11 @@ public function getRule(): Rule new ImpossibleCheckTypeMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php index 1846fd0cac3..27fa1390e57 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRuleTest.php @@ -25,11 +25,11 @@ public function getRule(): Rule new ImpossibleCheckTypeStaticMethodCallRule( new ImpossibleCheckTypeHelper( self::createReflectionProvider(), - $this->getTypeSpecifier(), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, $this->reportAlwaysTrueInLastCondition, true, diff --git a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php index 158350103ea..834386423f5 100644 --- a/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/LogicalXorConstantConditionRuleTest.php @@ -19,19 +19,52 @@ protected function getRule(): TRule return new CompositeRule([ new LogicalXorConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->shouldTreatPhpDocTypesAsCertain(), - ), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), false, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + false, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + false, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + false, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php index ae37a243f9d..35a91c5a751 100644 --- a/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/MatchExpressionRuleTest.php @@ -21,17 +21,50 @@ protected function getRule(): Rule return new CompositeRule([ new MatchExpressionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } @@ -307,7 +340,16 @@ public function testBug8932(): void public function testBug8937(): void { $this->treatPhpDocTypesAsCertain = false; - $this->analyse([__DIR__ . '/data/bug-8937.php'], []); + $this->analyse([__DIR__ . '/data/bug-8937.php'], [ + [ + 'Call to function is_array() with array will always evaluate to true.', + 23, + ], + [ + 'Call to function is_array() with non-empty-array will always evaluate to true.', + 24, + ], + ]); } #[RequiresPhp('>= 8.0.0')] diff --git a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php index 3da1fd10bed..4c40a02e14b 100644 --- a/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php @@ -1093,6 +1093,11 @@ public function testBug11609(): void 10, 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', ], + [ + 'Strict comparison using !== between string and null will always evaluate to true.', + 13, + 'Because the type is coming from a PHPDoc, you can turn off this check by setting treatPhpDocTypesAsCertain: false in your %configurationFile%.', + ], ]); } diff --git a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php index cda724eec1c..ba69d01a77f 100644 --- a/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/TernaryOperatorConstantConditionRuleTest.php @@ -21,18 +21,51 @@ protected function getRule(): Rule return new CompositeRule([ new TernaryOperatorConstantConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->treatPhpDocTypesAsCertain, - ), $this->treatPhpDocTypesAsCertain, ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->treatPhpDocTypesAsCertain, true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->treatPhpDocTypesAsCertain, + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->treatPhpDocTypesAsCertain, + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php index 93403a0a514..0ed691a60b4 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysFalseConditionRuleTest.php @@ -19,18 +19,51 @@ protected function getRule(): Rule return new CompositeRule([ new WhileLoopAlwaysFalseConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->shouldTreatPhpDocTypesAsCertain(), - ), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php index bb9fd1499ff..23846895d88 100644 --- a/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/WhileLoopAlwaysTrueConditionRuleTest.php @@ -19,18 +19,51 @@ protected function getRule(): Rule return new CompositeRule([ new WhileLoopAlwaysTrueConditionRule( new ConstantConditionRuleHelper( - new ImpossibleCheckTypeHelper( - self::createReflectionProvider(), - $this->getTypeSpecifier(), - $this->shouldTreatPhpDocTypesAsCertain(), - ), $this->shouldTreatPhpDocTypesAsCertain(), ), new PossiblyImpureTipHelper(true), self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), $this->shouldTreatPhpDocTypesAsCertain(), true, ), + new ImpossibleCheckTypeFunctionCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new ImpossibleCheckTypeStaticMethodCallRule( + new ImpossibleCheckTypeHelper( + self::createReflectionProvider(), + $this->shouldTreatPhpDocTypesAsCertain(), + ), + new PossiblyImpureTipHelper(true), + self::getContainer()->getByType(ConstantConditionInTraitHelper::class), + self::getContainer()->getByType(FunctionCallConstantConditionHelper::class), + $this->shouldTreatPhpDocTypesAsCertain(), + true, + true, + ), + new FunctionCallConstantConditionRule(), new ConstantConditionInTraitRule(), ]); } diff --git a/tests/PHPStan/Rules/Comparison/data/constant-condition-function-call.php b/tests/PHPStan/Rules/Comparison/data/constant-condition-function-call.php new file mode 100644 index 00000000000..af99fff7e93 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/constant-condition-function-call.php @@ -0,0 +1,22 @@ + + */ +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/PhpDoc/MethodAssertRuleTest.php b/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php index e23fa3eb55f..338a83aaf21 100644 --- a/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/MethodAssertRuleTest.php @@ -92,10 +92,6 @@ public function testRule(): void 'Class MethodAssert\Foo referenced with incorrect case: MethodAssert\fOO.', 105, ], - [ - 'Assert references unknown $this->barProp.', - 105, - ], [ 'Assert references unknown parameter $this.', 113, diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index ca236634015..397dabed083 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1701,4 +1701,26 @@ public function testBug10090(): void ]); } + public function testBug2032(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-2032.php'], [ + [ + 'Undefined variable: $undefined', + 6, + ], + [ + 'Undefined variable: $undefined', + 9, + ], + [ + 'Undefined variable: $undefined', + 15, + ], + ]); + } + } 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 613e2688a5f..4847e879561 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 e22565393fe..7527336ec8e 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(), )); @@ -464,4 +462,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..5a7473efb56 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-12780.php @@ -0,0 +1,29 @@ +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/tests/PHPStan/Rules/Variables/data/bug-2032.php b/tests/PHPStan/Rules/Variables/data/bug-2032.php new file mode 100644 index 00000000000..65e9d1c5f62 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-2032.php @@ -0,0 +1,17 @@ +