Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
}
Expand Down
29 changes: 26 additions & 3 deletions src/Analyser/ExpressionTypeHolder.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ public function __construct(
private readonly Expr $expr,
private readonly Type $type,
private readonly TrinaryLogic $certainty,
private readonly ?Expr $assignedFromExpr = null,
)
{
}
Expand Down Expand Up @@ -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,
);
}

Expand All @@ -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);
}

}
22 changes: 21 additions & 1 deletion src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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,
);
}

Expand Down
21 changes: 21 additions & 0 deletions src/Analyser/TypeSpecifier.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
114 changes: 114 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14464-analogous.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
<?php declare(strict_types = 1);

namespace Bug14464Analogous;

use function PHPStan\Testing\assertType;

/**
* sizeof() alias
* @param list<int> $items
*/
function testSizeof(array $items): void {
$count = sizeof($items);
if ($count === 3) {
assertType('array{int, int, int}', $items);
}
}

/**
* Inline count still works
* @param list<int> $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<int> $items
*/
function testGreaterOrEqual(array $items): void {
$count = count($items);
if ($count >= 3) {
assertType('non-empty-list<int>', $items);
}
}

/**
* Count value > 8 (no longer limited by pre-computation)
* @param list<int> $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<int> since int values are not countable
* @param list<int> $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<string> $items
*/
function testNonEmptyList(array $items): void {
$count = count($items);
if ($count === 2) {
assertType('array{string, string}', $items);
}
}

/**
* Variable count with switch statement
* @param list<int> $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;
}
}
72 changes: 72 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14464.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php declare(strict_types = 1);

namespace Bug14464;

use function PHPStan\Testing\assertType;

class HelloWorld
{
/** Variable count with == (loose comparison from the issue) */
protected function columnOrAlias(string $columnName): void
{
$colParts = preg_split('/\s+/', $columnName, -1, \PREG_SPLIT_NO_EMPTY);
if ($colParts === false) {
throw new \RuntimeException('preg error');
}
assertType('list<non-empty-string>', $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<int> $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<int>', $items);
}
}

public function columnName(string $columnName): string
{
return 'abc';
}
}
Loading