Skip to content
Closed
Show file tree
Hide file tree
Changes from 2 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
35 changes: 35 additions & 0 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@
final class AssignHandler implements ExprHandler
{

private const INTEGER_CONDITIONAL_LIMIT = 8;

public function __construct(
private TypeSpecifier $typeSpecifier,
private PhpVersion $phpVersion,
Expand Down Expand Up @@ -313,6 +315,39 @@
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType);
}

if (
$assignedExpr instanceof FuncCall
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we remember the expression which a variable was assigned from, in case its a pure function call?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Here's a summary of the changes made to address staabm's review:

What changed

Instead of pre-computing conditional expressions for integer values 1-8 at assignment time (the INTEGER_CONDITIONAL_LIMIT loop), the code now remembers the original FuncCall expression that was assigned to a variable and resolves it dynamically when needed.

Architecture

  1. ExpressionTypeHolder - Added an optional assignedFromExpr field. Since ExpressionTypeHolder lives in the expressionTypes array which is already threaded through all scope operations, no changes to the MutatingScope constructor or the 37 scopeFactory->create() calls were needed.

  2. AssignHandler - After assignVariable(), if the assigned expression is a FuncCall, sets assignedFromExpr on the ExpressionTypeHolder. Removed the pre-computation loop and INTEGER_CONDITIONAL_LIMIT constant.

  3. TypeSpecifier::resolveIdentical() - When one side is a Variable with a remembered FuncCall, also resolves the comparison with the original FuncCall substituted in and unions the results.

  4. MutatingScope - Added getVariableAssignedFromExpr() method. Modified specifyExpressionType(), filterBySpecifiedTypes(), processScopeForBody(), and generalizeVariableTypeHolders() to preserve assignedFromExpr through type narrowing, branch merges, and loop body processing. The remembered expression is automatically cleared on variable reassignment (since invalidateExpression removes the old holder).

Benefits over pre-computation

  • No arbitrary limit: count($arr) === 10 now correctly produces array{int, int, int, int, int, int, int, int, int, int} (previously fell back to non-empty-list<int>)
  • More memory-efficient: One expression reference per variable vs N conditional expressions
  • No hardcoded function names: Works for any FuncCall, resolved dynamically by TypeSpecifier

Verification

  • All 11,856 tests pass
  • PHPStan self-analysis reports no errors

&& $type->isInteger()->yes()

Check warning on line 320 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $assignedExpr instanceof FuncCall - && $type->isInteger()->yes() + && !$type->isInteger()->no() && !$type instanceof ConstantIntegerType ) { for ($n = 1; $n <= self::INTEGER_CONDITIONAL_LIMIT; $n++) {

Check warning on line 320 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ if ( $assignedExpr instanceof FuncCall - && $type->isInteger()->yes() + && !$type->isInteger()->no() && !$type instanceof ConstantIntegerType ) { for ($n = 1; $n <= self::INTEGER_CONDITIONAL_LIMIT; $n++) {
&& !$type instanceof ConstantIntegerType
) {
for ($n = 1; $n <= self::INTEGER_CONDITIONAL_LIMIT; $n++) {
$nType = new ConstantIntegerType($n);
$identicalExpr = new Expr\BinaryOp\Identical(
$assignedExpr,
new Node\Scalar\Int_($n),
);
$identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
$scope,
$identicalExpr,
TypeSpecifierContext::createTrue(),
);
$conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign(
$scope,
$var->name,
$conditionalExpressions,
$identicalSpecifiedTypes,
$nType,
);
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign(
$scope,
$var->name,
$conditionalExpressions,
$identicalSpecifiedTypes,
$nType,
);
}
}

$nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage);
$scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes());
foreach ($conditionalExpressions as $exprString => $holders) {
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 (beyond pre-computed limit)
* @param list<int> $items
*/
function testBeyondLimit(array $items): void {
$count = count($items);
if ($count === 10) {
assertType('non-empty-list<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 - generalized integer pre-computation 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