Skip to content

Commit c8106c3

Browse files
phpstan-botondrejmirtes
authored andcommitted
Preserve non-empty array guarantee in ConstantArrayType::setOffsetValueType when union key expansion produces all-optional keys
- `ConstantArrayTypeBuilder` now expands union scalar keys (e.g. `'a'|'b'`) into individual optional entries even for empty arrays (since 2f66c45). Each key is optional because we don't know which branch of the union will be taken, but at least one WILL be set, so the result must be non-empty. - `ArrayType::setOffsetValueType` already intersects with `NonEmptyArrayType`, but `ConstantArrayType::setOffsetValueType` did not, causing the array shape `array{a?: 1, b?: 1}` to be reported as possibly empty. - Add `NonEmptyArrayType` intersection in `ConstantArrayType::setOffsetValueType` when the builder result's `isIterableAtLeastOnce()` is not `yes`. - Update pre-existing test that asserted the buggy (possibly-empty) behavior.
1 parent b6a75c6 commit c8106c3

5 files changed

Lines changed: 140 additions & 8 deletions

File tree

src/Type/Constant/ConstantArrayTypeBuilder.php

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ final class ConstantArrayTypeBuilder
4141

4242
private bool $oversized = false;
4343

44+
private TrinaryLogic $isNonEmpty;
45+
4446
/**
4547
* @param list<Type> $keyTypes
4648
* @param array<int, Type> $valueTypes
@@ -55,6 +57,7 @@ private function __construct(
5557
private TrinaryLogic $isList,
5658
)
5759
{
60+
$this->isNonEmpty = TrinaryLogic::createNo();
5861
}
5962

6063
public static function createEmpty(): self
@@ -71,6 +74,7 @@ public static function createFromConstantArray(ConstantArrayType $startArrayType
7174
$startArrayType->getOptionalKeys(),
7275
$startArrayType->isList(),
7376
);
77+
$builder->isNonEmpty = $startArrayType->isIterableAtLeastOnce();
7478

7579
if (count($startArrayType->getKeyTypes()) > self::ARRAY_COUNT_LIMIT) {
7680
$builder->degradeToGeneralArray(true);
@@ -89,6 +93,10 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt
8993
return;
9094
}
9195

96+
if (!$optional) {
97+
$this->isNonEmpty = TrinaryLogic::createYes();
98+
}
99+
92100
if (!$this->degradeToGeneralArray) {
93101
if (
94102
$valueType instanceof ClosureType
@@ -388,7 +396,11 @@ public function getArray(): Type
388396
if (!$this->degradeToGeneralArray) {
389397
/** @var list<ConstantIntegerType|ConstantStringType> $keyTypes */
390398
$keyTypes = $this->keyTypes;
391-
return new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList);
399+
$array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList);
400+
if ($this->isNonEmpty->yes() && !$array->isIterableAtLeastOnce()->yes()) {
401+
return TypeCombinator::intersect($array, new NonEmptyArrayType());
402+
}
403+
return $array;
392404
}
393405

394406
if ($this->degradeClosures === true) {
@@ -410,7 +422,7 @@ public function getArray(): Type
410422
);
411423

412424
$types = [];
413-
if (count($this->optionalKeys) < $keyTypesCount) {
425+
if ($this->isNonEmpty->yes() || count($this->optionalKeys) < $keyTypesCount) {
414426
$types[] = new NonEmptyArrayType();
415427
}
416428

tests/PHPStan/Analyser/nsrt/bug-14551.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function foreachTwoKeys(array $keys): void
1515
$result[$k]['y'] = 2;
1616
}
1717

18-
assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result);
18+
assertType("non-empty-array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result);
1919
}
2020

2121
/**
@@ -30,7 +30,7 @@ function foreachThreeKeys(array $keys): void
3030
$result[$k]['z'] = 3;
3131
}
3232

33-
assertType("array{a?: array{x: 1, y: 2, z: 3}, b?: array{x: 1, y: 2, z: 3}}", $result);
33+
assertType("non-empty-array{a?: array{x: 1, y: 2, z: 3}, b?: array{x: 1, y: 2, z: 3}}", $result);
3434
}
3535

3636
/**
@@ -43,7 +43,7 @@ function withoutForeach(string $k): void
4343
$result[$k]['x'] = 1;
4444
$result[$k]['y'] = 2;
4545

46-
assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result);
46+
assertType("non-empty-array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}}", $result);
4747
}
4848

4949
/**
@@ -56,7 +56,7 @@ function integerKeys(int $k): void
5656
$result[$k]['x'] = 1;
5757
$result[$k]['y'] = 2;
5858

59-
assertType("array{0?: array{x: 1, y: 2}, 1?: array{x: 1, y: 2}}", $result);
59+
assertType("non-empty-array{0?: array{x: 1, y: 2}, 1?: array{x: 1, y: 2}}", $result);
6060
}
6161

6262
/**
@@ -69,7 +69,7 @@ function threeWayUnion(string $k): void
6969
$result[$k]['x'] = 1;
7070
$result[$k]['y'] = 2;
7171

72-
assertType("array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}, c?: array{x: 1, y: 2}}", $result);
72+
assertType("non-empty-array{a?: array{x: 1, y: 2}, b?: array{x: 1, y: 2}, c?: array{x: 1, y: 2}}", $result);
7373
}
7474

7575
/**
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14552;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @param non-empty-list<'a'|'b'> $keys
9+
*/
10+
function nonEmptyListForeach(array $keys): void
11+
{
12+
$out = [];
13+
foreach ($keys as $k) {
14+
$out[$k] = 1;
15+
}
16+
assertType("non-empty-array{a?: 1, b?: 1}", $out);
17+
}
18+
19+
/**
20+
* @param list<'a'|'b'> $keys
21+
*/
22+
function possiblyEmptyListForeach(array $keys): void
23+
{
24+
$out = [];
25+
foreach ($keys as $k) {
26+
$out[$k] = 1;
27+
}
28+
assertType("array{}|array{a?: 1, b?: 1}", $out);
29+
}
30+
31+
/**
32+
* @param non-empty-list<'x'|'y'|'z'> $keys
33+
*/
34+
function nonEmptyListThreeKeys(array $keys): void
35+
{
36+
$out = [];
37+
foreach ($keys as $k) {
38+
$out[$k] = true;
39+
}
40+
assertType("non-empty-array{x?: true, y?: true, z?: true}", $out);
41+
}
42+
43+
/**
44+
* Direct assignment (non-foreach) with union key on empty array.
45+
* @param 'a'|'b' $key
46+
*/
47+
function directAssignment(string $key): void
48+
{
49+
$arr = [];
50+
$arr[$key] = 1;
51+
assertType("non-empty-array{a?: 1, b?: 1}", $arr);
52+
}
53+
54+
/**
55+
* Direct assignment with integer union key on empty array.
56+
* @param 0|1|2 $key
57+
*/
58+
function directAssignmentIntKey(int $key): void
59+
{
60+
$arr = [];
61+
$arr[$key] = 'val';
62+
assertType("non-empty-array{0?: 'val', 1?: 'val', 2?: 'val'}", $arr);
63+
}
64+
65+
/**
66+
* Setting union key on already non-empty array should stay non-empty.
67+
* @param 'x'|'y' $key
68+
*/
69+
function setOnNonEmptyArray(string $key): void
70+
{
71+
$arr = ['existing' => 0];
72+
$arr[$key] = 1;
73+
assertType("array{existing: 0, x?: 1, y?: 1}", $arr);
74+
}

tests/PHPStan/Analyser/nsrt/set-union-offset-preserve-constant-array.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ public function doFoo(): void
1313

1414
$k = rand(0, 1) ? 1 : 2;
1515
$a[$k] = true;
16-
assertType('array{1?: true, 2?: true}', $a);
16+
assertType('non-empty-array{1?: true, 2?: true}', $a);
1717
}
1818

1919
}

tests/PHPStan/Type/Constant/ConstantArrayTypeBuilderTest.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,4 +267,50 @@ public function testSetOffsetValueTypeOnConstantArrayWithEmptyNextAutoIndexesRet
267267
$this->assertInstanceOf(ErrorType::class, $result);
268268
}
269269

270+
public function testNonOptionalUnionOffsetOnEmptyArrayIsNonEmpty(): void
271+
{
272+
$builder = ConstantArrayTypeBuilder::createEmpty();
273+
274+
$aOrB = TypeCombinator::union(
275+
new ConstantStringType('a'),
276+
new ConstantStringType('b'),
277+
);
278+
$builder->setOffsetValueType($aOrB, new ConstantIntegerType(1));
279+
280+
$array = $builder->getArray();
281+
$this->assertSame('non-empty-array{a?: 1, b?: 1}', $array->describe(VerbosityLevel::precise()));
282+
}
283+
284+
public function testOptionalSingleOffsetOnEmptyArrayIsPossiblyEmpty(): void
285+
{
286+
$builder = ConstantArrayTypeBuilder::createEmpty();
287+
$builder->setOffsetValueType(new ConstantStringType('a'), new ConstantIntegerType(1), true);
288+
289+
$array = $builder->getArray();
290+
$this->assertSame('array{a?: 1}', $array->describe(VerbosityLevel::precise()));
291+
}
292+
293+
public function testOptionalUnionOffsetOnEmptyArrayIsPossiblyEmpty(): void
294+
{
295+
$builder = ConstantArrayTypeBuilder::createEmpty();
296+
297+
$aOrB = TypeCombinator::union(
298+
new ConstantStringType('a'),
299+
new ConstantStringType('b'),
300+
);
301+
$builder->setOffsetValueType($aOrB, new ConstantIntegerType(1), true);
302+
303+
$array = $builder->getArray();
304+
$this->assertSame('array{a?: 1, b?: 1}', $array->describe(VerbosityLevel::precise()));
305+
}
306+
307+
public function testOptionalNullOffsetOnEmptyArrayIsPossiblyEmpty(): void
308+
{
309+
$builder = ConstantArrayTypeBuilder::createEmpty();
310+
$builder->setOffsetValueType(null, new ConstantIntegerType(1), true);
311+
312+
$array = $builder->getArray();
313+
$this->assertSame('array{0?: 1}', $array->describe(VerbosityLevel::precise()));
314+
}
315+
270316
}

0 commit comments

Comments
 (0)