Skip to content
Merged
Show file tree
Hide file tree
Changes from 24 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
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
9 changes: 8 additions & 1 deletion src/Analyser/MutatingScope.php
Original file line number Diff line number Diff line change
Expand Up @@ -4308,7 +4308,14 @@ public function specifyExpressionType(Expr $expr, Type $type, Type $nativeType,
}

$scope = $this;
if ($expr instanceof Expr\ArrayDimFetch && $expr->dim !== null) {
if (
$expr instanceof Expr\ArrayDimFetch
&& $expr->dim !== null
&& !$expr->dim instanceof Expr\PreInc
&& !$expr->dim instanceof Expr\PreDec
&& !$expr->dim instanceof Expr\PostDec
&& !$expr->dim instanceof Expr\PostInc
Comment on lines +4314 to +4317
Copy link
Contributor Author

@staabm staabm Sep 27, 2025

Choose a reason for hiding this comment

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

required change to not regress existing tests like

<?php

$anotherIndex = 0;
$postIncArray = [];
$postIncArray[$anotherIndex++] = $anotherIndex++;
\PHPStan\Testing\assertType('array{1}', $postIncArray);

) {
$dimType = $scope->getType($expr->dim)->toArrayKey();
if ($dimType->isInteger()->yes() || $dimType->isString()->yes()) {
$exprVarType = $scope->getType($expr->var);
Expand Down
73 changes: 53 additions & 20 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -5705,11 +5705,13 @@ private function processAssignVar(
}
$offsetValueType = $varType;
$offsetNativeValueType = $varNativeType;
$additionalExpressions = [];

$valueToWrite = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope);
$valueToWrite = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetTypes, $offsetValueType, $valueToWrite, $scope, $additionalExpressions);

if (!$offsetValueType->equals($offsetNativeValueType) || !$valueToWrite->equals($nativeValueToWrite)) {
$nativeValueToWrite = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope);
$additionalNativeExpressions = [];
$nativeValueToWrite = $this->produceArrayDimFetchAssignValueToWrite($dimFetchStack, $offsetNativeTypes, $offsetNativeValueType, $nativeValueToWrite, $scope, $additionalNativeExpressions);
} else {
$rewritten = false;
foreach ($offsetTypes as $i => $offsetType) {
Expand Down Expand Up @@ -5781,6 +5783,16 @@ private function processAssignVar(
}
}

foreach ($additionalExpressions as $k => $additionalExpression) {
[$expr, $type] = $additionalExpression;
$nativeType = $type;
if (isset($additionalNativeExpressions[$k])) {
[, $nativeType] = $additionalNativeExpressions[$k];
}

$scope = $scope->assignExpression($expr, $type, $nativeType);
}

if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) {
$throwPoints = array_merge($throwPoints, $this->processExprNode(
$stmt,
Expand Down Expand Up @@ -6134,9 +6146,12 @@ private function isImplicitArrayCreation(array $dimFetchStack, Scope $scope): Tr
/**
* @param list<ArrayDimFetch> $dimFetchStack
* @param list<Type|null> $offsetTypes
* @param list<array{Expr, Type}> $additionalExpressions
Copy link
Member

Choose a reason for hiding this comment

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

I'm not a fan of by-ref parameters. I'd rather if the method returned array{Type, list<array{Expr, Type}>} 😊

Copy link
Contributor Author

Choose a reason for hiding this comment

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

done

*/
private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, array $offsetTypes, Type $offsetValueType, Type $valueToWrite, Scope $scope): Type
private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, array $offsetTypes, Type $offsetValueType, Type $valueToWrite, Scope $scope, array &$additionalExpressions = []): Type
{
$originalValueToWrite = $valueToWrite;

$offsetValueTypeStack = [$offsetValueType];
foreach (array_slice($offsetTypes, 0, -1) as $offsetType) {
if ($offsetType === null) {
Expand Down Expand Up @@ -6204,25 +6219,43 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar
continue;
}

if ($scope->hasExpressionType($arrayDimFetch)->yes()) { // keep list for $list[$index] assignments
if (!$arrayDimFetch->dim instanceof BinaryOp\Plus) {
Copy link
Contributor Author

@staabm staabm Sep 28, 2025

Choose a reason for hiding this comment

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

@ondrejmirtes why is the issue related to Plus? there is no + contained in the related code example?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

ohh.. the Plus is related to the list-inference logic below.

so it was a bug lingering for longer already :)

continue;
}

if ( // keep list for $list[$index + 1] assignments
$arrayDimFetch->dim->right instanceof Variable
&& $arrayDimFetch->dim->left instanceof Node\Scalar\Int_
&& $arrayDimFetch->dim->left->value === 1
&& $scope->hasExpressionType(new ArrayDimFetch($arrayDimFetch->var, $arrayDimFetch->dim->right))->yes()
) {
$valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType());
} elseif ($arrayDimFetch->dim instanceof BinaryOp\Plus) {
if ( // keep list for $list[$index + 1] assignments
$arrayDimFetch->dim->right instanceof Variable
&& $arrayDimFetch->dim->left instanceof Node\Scalar\Int_
&& $arrayDimFetch->dim->left->value === 1
&& $scope->hasExpressionType(new ArrayDimFetch($arrayDimFetch->var, $arrayDimFetch->dim->right))->yes()
) {
$valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType());
} elseif ( // keep list for $list[1 + $index] assignments
$arrayDimFetch->dim->left instanceof Variable
&& $arrayDimFetch->dim->right instanceof Node\Scalar\Int_
&& $arrayDimFetch->dim->right->value === 1
&& $scope->hasExpressionType(new ArrayDimFetch($arrayDimFetch->var, $arrayDimFetch->dim->left))->yes()
) {
$valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType());
}
} elseif ( // keep list for $list[1 + $index] assignments
$arrayDimFetch->dim->left instanceof Variable
&& $arrayDimFetch->dim->right instanceof Node\Scalar\Int_
&& $arrayDimFetch->dim->right->value === 1
&& $scope->hasExpressionType(new ArrayDimFetch($arrayDimFetch->var, $arrayDimFetch->dim->left))->yes()
) {
$valueToWrite = TypeCombinator::intersect($valueToWrite, new AccessoryArrayListType());
}
}

$offsetValueType = $valueToWrite;
$lastDimKey = array_key_last($dimFetchStack);
foreach ($dimFetchStack as $key => $dimFetch) {
if ($dimFetch->dim === null) {
$additionalExpressions = [];
break;
}

if ($key === $lastDimKey) {
$offsetValueType = $originalValueToWrite;
} else {
$offsetType = $scope->getType($dimFetch->dim);
$offsetValueType = $offsetValueType->getOffsetValueType($offsetType);
}

$additionalExpressions[] = [$dimFetch, $offsetValueType];
}

return $valueToWrite;
Expand Down
1 change: 1 addition & 0 deletions tests/PHPStan/Analyser/NodeScopeResolverTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ private static function findTestFiles(): iterable
yield __DIR__ . '/../Rules/Generics/data/bug-3769.php';
yield __DIR__ . '/../Rules/Generics/data/bug-6301.php';
yield __DIR__ . '/../Rules/PhpDoc/data/bug-4643.php';
yield __DIR__ . '/../Rules/Arrays/data/bug-13538.php';

if (PHP_VERSION_ID >= 80000) {
yield __DIR__ . '/../Rules/Comparison/data/bug-4857.php';
Expand Down
41 changes: 41 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13214.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

namespace Bug13214;

use function PHPStan\Testing\assertType;
use stdClass;

class HelloWorld
{
/**
* @param ArrayAccess<int, ?object> $array
*/
public function sayHello(ArrayAccess $array): void
{
$child = new stdClass();

assert($array[1] === null);

assertType('null', $array[1]);

$array[1] = $child;

assertType(stdClass::class, $array[1]);
}

/**
* @param array<int, ?object> $array
*/
public function sayHelloArray(array $array): void
{
$child = new stdClass();

assert(($array[1] ?? null) === null);

assertType('object|null', $array[1]);

$array[1] = $child;

assertType(stdClass::class, $array[1]);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1007,4 +1007,28 @@ public function testBug12926(): void
$this->analyse([__DIR__ . '/data/bug-12926.php'], []);
}

public function testBug13538(): void
{
$this->reportPossiblyNonexistentConstantArrayOffset = true;
$this->reportPossiblyNonexistentGeneralArrayOffset = true;

$this->analyse([__DIR__ . '/data/bug-13538.php'], [
[
"Offset int might not exist on non-empty-array<int, ''>.",
13,
],
[
"Offset int might not exist on non-empty-array<int, ''>.",
17,
],
]);
}

public function testBug12805(): void
{
$this->reportPossiblyNonexistentGeneralArrayOffset = true;

$this->analyse([__DIR__ . '/data/bug-12805.php'], []);
}

}
23 changes: 23 additions & 0 deletions tests/PHPStan/Rules/Arrays/data/bug-12805.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php declare(strict_types = 1);

namespace Bug12805;

/**
* @param array<string, array{ rtx?: int }> $operations
* @return array<string, array{ rtx: int }>
*/
function bug(array $operations): array {
$base = [];

foreach ($operations as $operationName => $operation) {
if (!isset($base[$operationName])) {
$base[$operationName] = [];
}
if (!isset($base[$operationName]['rtx'])) {
$base[$operationName]['rtx'] = 0;
}
$base[$operationName]['rtx'] += $operation['rtx'] ?? 0;
}

return $base;
}
61 changes: 61 additions & 0 deletions tests/PHPStan/Rules/Arrays/data/bug-13538.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace Bug13538;

use LogicException;
use function PHPStan\Testing\assertType;

/** @param list<string> $arr */
function doFoo(array $arr, int $i, int $i2): void
{
$logs = [];
$logs[$i] = '';
echo $logs[$i2];

assertType("non-empty-array<int, ''>", $logs);
assertType("''", $logs[$i]);
assertType("''", $logs[$i2]); // could be mixed

foreach ($arr as $value) {
echo $logs[$i];

assertType("non-empty-array<int, ''>", $logs);
assertType("''", $logs[$i]);
}
}

/** @param list<string> $arr */
function doFooBar(array $arr): void
{
if (!defined('LOG_DIR')) {
throw new LogicException();
}

$logs = [];
$logs[LOG_DIR] = '';

assertType("non-empty-array<''>", $logs);
assertType("''", $logs[LOG_DIR]);

foreach ($arr as $value) {
echo $logs[LOG_DIR];

assertType("non-empty-array<''>", $logs);
assertType("''", $logs[LOG_DIR]);
}
}

function doBar(array $arr, int $i, string $s): void
{
$logs = [];
$logs[$i][$s] = '';
assertType("non-empty-array<int, non-empty-array<string, ''>>", $logs);
assertType("non-empty-array<string, ''>", $logs[$i]);
assertType("''", $logs[$i][$s]);
foreach ($arr as $value) {
assertType("non-empty-array<int, non-empty-array<string, ''>>", $logs);
assertType("non-empty-array<string, ''>", $logs[$i]);
assertType("''", $logs[$i][$s]);
echo $logs[$i][$s];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1121,4 +1121,10 @@ public function testBug7773(): void
$this->analyse([__DIR__ . '/data/bug-7773.php'], []);
}

public function testPr4375(): void
{
$this->treatPhpDocTypesAsCertain = true;
$this->analyse([__DIR__ . '/data/pr-4375.php'], []);
}

}
27 changes: 27 additions & 0 deletions tests/PHPStan/Rules/Comparison/data/pr-4375.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php

namespace PR4375;

final class Foo
{
public function processNode(): array
{
$methods = [];
foreach ($this->get() as $collected) {
foreach ($collected as [$className, $methodName, $classDisplayName]) {
$className = strtolower($className);

if (!array_key_exists($className, $methods)) {
$methods[$className] = [];
}
$methods[$className][strtolower($methodName)] = $classDisplayName . '::' . $methodName;
}
}

return [];
}

private function get(): array {
return [];
}
}
11 changes: 11 additions & 0 deletions tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1265,4 +1265,15 @@ public function testBug7225(): void
$this->analyse([__DIR__ . '/data/bug-7225.php'], []);
}

public function testDeepDimFetch(): void
{
$this->analyse([__DIR__ . '/data/deep-dim-fetch.php'], []);
}

#[RequiresPhp('>= 8.0')]
public function testBug9494(): void
{
$this->analyse([__DIR__ . '/data/bug-9494.php'], []);
}

}
53 changes: 53 additions & 0 deletions tests/PHPStan/Rules/Methods/data/bug-9494.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

namespace Bug9494;

class Fib
{
/** @var array<int, ?int> 0-indexed memoization */
protected $mem = [];

public function __construct(public int $limit) {
$this->mem = array_fill(2, $limit, null);
}

/**
* Calculate fib, 1-indexed
*/
public function fib(int $n): int
{
if ($n < 1 || $n > $this->limit) {
throw new \RangeException();
}

if ($n == 1 || $n == 2) {
return 1;
}

if (is_null($this->mem[$n - 1])) {
$this->mem[$n - 1] = $this->fib($n - 1) + $this->fib($n - 2);
}

return $this->mem[$n - 1]; // Is always an int at this stage
}

/**
* Calculate fib, 0-indexed
*/
public function fib0(int $n0): int
{
if ($n0 < 0 || $n0 >= $this->limit) {
throw new \RangeException();
}

if ($n0 == 0 || $n0 == 1) {
return 1;
}

if (is_null($this->mem[$n0])) {
$this->mem[$n0] = $this->fib0($n0 - 1) + $this->fib0($n0 - 2);
}

return $this->mem[$n0];
}
}
Loading
Loading