-
Notifications
You must be signed in to change notification settings - Fork 571
Pre-compute count-specific conditional expressions to narrow list types when count() is stored in a variable
#5461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
83af88c
5b65941
74353c3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
…es when `count()` is stored in a variable
- When `$count = count($list)` is assigned, pre-compute conditional
expressions for count values 1-8 so that `$count === N` narrows
`$list` to the exact array shape (e.g. `array{T, T, T}` for N=3)
- Previously, only inline `count($list) === 3` narrowed correctly;
storing the count in a variable only gave `non-empty-list<T>`
- The fix extends AssignHandler to call specifyTypesInCondition with
synthetic `count($expr) === N` comparisons for small N values,
storing the results as ConditionalExpressionHolders
- Works for count() and sizeof() with a single argument on list and
constant array types
- Analogous cases verified: sizeof() alias, explode() results,
non-empty-list types, switch statements, PHPDoc list types
- strlen() variable narrowing is a separate pre-existing issue with
a different mechanism (no TypeSpecifyingExtension) — not addressed- Loading branch information
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -73,6 +73,7 @@ | |
| use function in_array; | ||
| use function is_int; | ||
| use function is_string; | ||
| use function strtolower; | ||
|
|
||
| /** | ||
| * @implements ExprHandler<Assign|AssignRef> | ||
|
|
@@ -81,6 +82,8 @@ | |
| final class AssignHandler implements ExprHandler | ||
| { | ||
|
|
||
| private const COUNT_CONDITIONAL_LIMIT = 8; | ||
|
|
||
| public function __construct( | ||
| private TypeSpecifier $typeSpecifier, | ||
| private PhpVersion $phpVersion, | ||
|
|
@@ -313,6 +316,44 @@ | |
| $conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType); | ||
| } | ||
|
|
||
| if ( | ||
| $assignedExpr instanceof FuncCall | ||
| && $assignedExpr->name instanceof Name | ||
| && in_array(strtolower($assignedExpr->name->toString()), ['count', 'sizeof'], true) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why do we need a hardcoded special case for count()?
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done. Here's what I changed to address the reviewer's feedback: The concern: staabm asked why we need a hardcoded special case for The fix: Generalized the pre-computation to work for any Changes:
Verification: All 11,856 tests pass, PHPStan self-analysis reports no errors. |
||
| && count($assignedExpr->getArgs()) === 1 | ||
| && !$type instanceof ConstantIntegerType | ||
| ) { | ||
| $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); | ||
| if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { | ||
|
Check warning on line 327 in src/Analyser/ExprHandler/AssignHandler.php
|
||
| for ($n = 1; $n <= self::COUNT_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) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,102 @@ | ||
| <?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 excluded from pre-computation | ||
| * @param list<int> $items | ||
| */ | ||
| function testCountWithMode(array $items, int $mode): void { | ||
| $count = count($items, $mode); | ||
| if ($count === 3) { | ||
| assertType('non-empty-list<int>', $items); | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * 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; | ||
| } | ||
| } |
| 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'; | ||
| } | ||
| } |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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_LIMITloop), the code now remembers the original FuncCall expression that was assigned to a variable and resolves it dynamically when needed.Architecture
ExpressionTypeHolder- Added an optionalassignedFromExprfield. Since ExpressionTypeHolder lives in theexpressionTypesarray which is already threaded through all scope operations, no changes to the MutatingScope constructor or the 37scopeFactory->create()calls were needed.AssignHandler- AfterassignVariable(), if the assigned expression is aFuncCall, setsassignedFromExpron the ExpressionTypeHolder. Removed the pre-computation loop andINTEGER_CONDITIONAL_LIMITconstant.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.MutatingScope- AddedgetVariableAssignedFromExpr()method. ModifiedspecifyExpressionType(),filterBySpecifiedTypes(),processScopeForBody(), andgeneralizeVariableTypeHolders()to preserveassignedFromExprthrough type narrowing, branch merges, and loop body processing. The remembered expression is automatically cleared on variable reassignment (sinceinvalidateExpressionremoves the old holder).Benefits over pre-computation
count($arr) === 10now correctly producesarray{int, int, int, int, int, int, int, int, int, int}(previously fell back tonon-empty-list<int>)Verification