diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 51995ac665..8a237ca2ac 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -319,6 +319,14 @@ public function processAssignVar( $scope = $scope->addConditionalExpressions($exprString, $holders); } + if ($assignedExpr instanceof FuncCall) { + $varExprString = '$' . $var->name; + $existingHolder = $scope->expressionTypes[$varExprString] ?? null; + if ($existingHolder !== null) { + $scope->expressionTypes[$varExprString] = $existingHolder->withAssignedFromExpr($assignedExpr); + } + } + if ($assignedExpr instanceof Expr\Array_) { $scope = $this->processArrayByRefItems($scope, $var->name, $assignedExpr, new Variable($var->name)); } diff --git a/src/Analyser/ExpressionTypeHolder.php b/src/Analyser/ExpressionTypeHolder.php index 7477dbf3dc..3f02127669 100644 --- a/src/Analyser/ExpressionTypeHolder.php +++ b/src/Analyser/ExpressionTypeHolder.php @@ -14,6 +14,7 @@ public function __construct( private readonly Expr $expr, private readonly Type $type, private readonly TrinaryLogic $certainty, + private readonly ?Expr $assignedFromExpr = null, ) { } @@ -52,22 +53,34 @@ public function equals(self $other): bool public function and(self $other): self { + $assignedFromExpr = $this->assignedFromExpr === $other->assignedFromExpr ? $this->assignedFromExpr : null; + if ($this->type === $other->type || $this->type->equals($other->type)) { if ($this->certainty->and($other->certainty)->yes()) { - return $this; + if ($assignedFromExpr === $this->assignedFromExpr) { + return $this; + } + return $this->withAssignedFromExpr($assignedFromExpr); } if ($this->certainty->maybe()) { - return $this; + if ($assignedFromExpr === $this->assignedFromExpr) { + return $this; + } + return $this->withAssignedFromExpr($assignedFromExpr); } - return $other; + if ($assignedFromExpr === $other->assignedFromExpr) { + return $other; + } + return $other->withAssignedFromExpr($assignedFromExpr); } return new self( $this->expr, TypeCombinator::union($this->type, $other->type), $this->certainty->and($other->certainty), + $assignedFromExpr, ); } @@ -86,4 +99,14 @@ public function getCertainty(): TrinaryLogic return $this->certainty; } + public function getAssignedFromExpr(): ?Expr + { + return $this->assignedFromExpr; + } + + public function withAssignedFromExpr(?Expr $assignedFromExpr): self + { + return new self($this->expr, $this->type, $this->certainty, $assignedFromExpr); + } + } diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 122340cc27..e8cb6b70bb 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -685,6 +685,16 @@ public function getVariableType(string $variableName): Type return $this->expressionTypes[$varExprString]->getType(); } + public function getVariableAssignedFromExpr(string $variableName): ?Expr + { + $varExprString = '$' . $variableName; + if (!array_key_exists($varExprString, $this->expressionTypes)) { + return null; + } + + return $this->expressionTypes[$varExprString]->getAssignedFromExpr(); + } + /** * @api * @return list @@ -2765,7 +2775,8 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType, $exprString = $this->getNodeKey($expr); $expressionTypes = $scope->expressionTypes; - $expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty); + $existingAssignedFromExpr = ($expressionTypes[$exprString] ?? null)?->getAssignedFromExpr(); + $expressionTypes[$exprString] = new ExpressionTypeHolder($expr, $type, $certainty, $existingAssignedFromExpr); $nativeTypes = $scope->nativeExpressionTypes; $nativeTypes[$exprString] = new ExpressionTypeHolder($expr, $nativeType, $certainty); @@ -3272,6 +3283,7 @@ public function filterBySpecifiedTypes(SpecifiedTypes $specifiedTypes): self $scope->expressionTypes[$conditionalExprString]->getExpr(), TypeCombinator::intersect($scope->expressionTypes[$conditionalExprString]->getType(), $type), TrinaryLogic::maxMin($scope->expressionTypes[$conditionalExprString]->getCertainty(), $certainty), + $scope->expressionTypes[$conditionalExprString]->getAssignedFromExpr(), ); } else { $scope->expressionTypes[$conditionalExprString] = $expressions[0]->getTypeHolder(); @@ -3887,10 +3899,14 @@ public function processAlwaysIterableForeachScopeWithoutPollute(self $finalScope continue; } + $assignedFromExpr = $variableTypeHolder->getAssignedFromExpr() === $expressionTypes[$variableExprString]->getAssignedFromExpr() + ? $variableTypeHolder->getAssignedFromExpr() + : null; $expressionTypes[$variableExprString] = new ExpressionTypeHolder( $variableTypeHolder->getExpr(), $variableTypeHolder->getType(), $variableTypeHolder->getCertainty()->and($expressionTypes[$variableExprString]->getCertainty()), + $assignedFromExpr, ); } $nativeTypes = $this->nativeExpressionTypes; @@ -3991,10 +4007,14 @@ private function generalizeVariableTypeHolders( ) { $generalizedExpressions[$variableExprString] = $variableTypeHolder->getExpr(); } + $assignedFromExpr = $variableTypeHolder->getAssignedFromExpr() === $otherVariableTypeHolders[$variableExprString]->getAssignedFromExpr() + ? $variableTypeHolder->getAssignedFromExpr() + : null; $newVariableTypeHolders[$variableExprString] = new ExpressionTypeHolder( $variableTypeHolder->getExpr(), $generalizedType, $variableTypeHolder->getCertainty(), + $assignedFromExpr, ); } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3d4475d593..c818033833 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2683,6 +2683,27 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty ); } + // When a variable was assigned from a FuncCall, also resolve with the original expression + if ($scope instanceof MutatingScope && !$leftExpr instanceof FuncCall && !$rightExpr instanceof FuncCall) { + $rememberedExpr = null; + if ($leftExpr instanceof Expr\Variable && is_string($leftExpr->name)) { + $rememberedExpr = $scope->getVariableAssignedFromExpr($leftExpr->name); + } elseif ($rightExpr instanceof Expr\Variable && is_string($rightExpr->name)) { + $rememberedExpr = $scope->getVariableAssignedFromExpr($rightExpr->name); + } + + if ($rememberedExpr instanceof FuncCall) { + $substitutedExpr = $rememberedExpr; + $otherExpr = $leftExpr instanceof Expr\Variable && is_string($leftExpr->name) ? $rightExpr : $leftExpr; + $funcCallSpecifiedTypes = $this->resolveNormalizedIdentical( + new Expr\BinaryOp\Identical($substitutedExpr, $otherExpr), + $scope, + $context, + ); + $specifiedTypes = $specifiedTypes->unionWith($funcCallSpecifiedTypes); + } + } + return $specifiedTypes; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14464-analogous.php b/tests/PHPStan/Analyser/nsrt/bug-14464-analogous.php new file mode 100644 index 0000000000..836b58446d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14464-analogous.php @@ -0,0 +1,114 @@ + $items + */ +function testSizeof(array $items): void { + $count = sizeof($items); + if ($count === 3) { + assertType('array{int, int, int}', $items); + } +} + +/** + * Inline count still works + * @param list $items + */ +function testInlineCount(array $items): void { + if (count($items) === 3) { + assertType('array{int, int, int}', $items); + } +} + +/** + * explode() result + */ +function testExplode(string $input): void { + $parts = explode(',', $input); + $count = count($parts); + if ($count === 3) { + assertType('array{string, string, string}', $parts); + } elseif ($count === 1) { + assertType('array{string}', $parts); + } +} + +/** + * Variable count >= N (range comparison) + * @param list $items + */ +function testGreaterOrEqual(array $items): void { + $count = count($items); + if ($count >= 3) { + assertType('non-empty-list', $items); + } +} + +/** + * Count value > 8 (no longer limited by pre-computation) + * @param list $items + */ +function testBeyondLimit(array $items): void { + $count = count($items); + if ($count === 10) { + assertType('array{int, int, int, int, int, int, int, int, int, int}', $items); + } +} + +/** + * Count with mode argument - safe for list since int values are not countable + * @param list $items + */ +function testCountWithMode(array $items, int $mode): void { + $count = count($items, $mode); + if ($count === 3) { + assertType('array{int, int, int}', $items); + } +} + +/** + * Variable strlen - remembered expression also works for strlen + */ +function testStrlen(string $s): void { + $len = strlen($s); + if ($len === 3) { + assertType('non-falsy-string', $s); + } elseif ($len === 1) { + assertType('non-empty-string', $s); + } +} + +/** + * Variable count on non-empty-list + * @param non-empty-list $items + */ +function testNonEmptyList(array $items): void { + $count = count($items); + if ($count === 2) { + assertType('array{string, string}', $items); + } +} + +/** + * Variable count with switch statement + * @param list $items + */ +function testSwitch(array $items): void { + $count = count($items); + switch ($count) { + case 1: + assertType('array{int}', $items); + break; + case 2: + assertType('array{int, int}', $items); + break; + case 3: + assertType('array{int, int, int}', $items); + break; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-14464.php b/tests/PHPStan/Analyser/nsrt/bug-14464.php new file mode 100644 index 0000000000..65bef90581 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14464.php @@ -0,0 +1,72 @@ +', $colParts); + $numParts = count($colParts); + + if ($numParts == 3) { + assertType('array{non-empty-string, non-empty-string, non-empty-string}', $colParts); + $this->columnName($colParts[0]); + $this->columnName($colParts[1]); + $this->columnName($colParts[2]); + } elseif ($numParts == 2) { + assertType('array{non-empty-string, non-empty-string}', $colParts); + $this->columnName($colParts[0]); + $this->columnName($colParts[1]); + } elseif ($numParts == 1) { + assertType('array{non-empty-string}', $colParts); + $this->columnName($colParts[0]); + } else { + throw new \LogicException('invalid'); + } + } + + /** Variable count with === (strict comparison) */ + protected function strictComparison(string $input): void + { + $parts = preg_split('/,/', $input, -1, \PREG_SPLIT_NO_EMPTY); + if ($parts === false) { + throw new \RuntimeException('preg error'); + } + $count = count($parts); + + if ($count === 3) { + assertType('array{non-empty-string, non-empty-string, non-empty-string}', $parts); + } elseif ($count === 1) { + assertType('array{non-empty-string}', $parts); + } + } + + /** + * Variable count on a PHPDoc list type + * @param list $items + */ + protected function phpdocList(array $items): void + { + $count = count($items); + if ($count === 3) { + assertType('array{int, int, int}', $items); + } elseif ($count === 5) { + assertType('array{int, int, int, int, int}', $items); + } else { + assertType('list', $items); + } + } + + public function columnName(string $columnName): string + { + return 'abc'; + } +}