Skip to content

Commit 145c46f

Browse files
committed
Fix phpstan/phpstan#14314: strange behavior on regex capture groups (phpstan#5239)
1 parent 580065f commit 145c46f

2 files changed

Lines changed: 139 additions & 0 deletions

File tree

src/Analyser/TypeSpecifier.php

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1294,6 +1294,27 @@ private function specifyTypesForCountFuncCall(
12941294
return $result;
12951295
}
12961296
}
1297+
1298+
// Fallback: directly filter constant arrays by their exact sizes.
1299+
// This avoids using TypeCombinator::remove() with falsey context,
1300+
// which can incorrectly remove arrays whose count doesn't match
1301+
// but whose shape is a subtype of the matched array.
1302+
$keptTypes = [];
1303+
foreach ($type->getConstantArrays() as $arrayType) {
1304+
if ($sizeType->isSuperTypeOf($arrayType->getArraySize())->yes()) {
1305+
continue;
1306+
}
1307+
1308+
$keptTypes[] = $arrayType;
1309+
}
1310+
if ($keptTypes !== []) {
1311+
return $this->create(
1312+
$countFuncCall->getArgs()[0]->value,
1313+
TypeCombinator::union(...$keptTypes),
1314+
$context->negate(),
1315+
$scope,
1316+
)->setRootExpr($rootExpr);
1317+
}
12971318
}
12981319

12991320
$resultTypes = [];
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
<?php
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14314;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
function () {
10+
preg_match('/^(.)$/', '', $matches) || preg_match('/^(.)(.)(.)$/', '', $matches);
11+
assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string}', $matches);
12+
if (count($matches) === 2) {
13+
assertType('array{non-falsy-string, non-empty-string}', $matches);
14+
return;
15+
}
16+
assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches);
17+
if (count($matches) === 4) {
18+
assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches);
19+
}
20+
};
21+
22+
class Bug14314Test
23+
{
24+
/**
25+
* Test: union with array{} and arrays with optional keys
26+
* @param array{}|array{0: string, 1: string, 2?: string} $arr
27+
*/
28+
public function testOptionalKeysWithEmpty(array $arr): void
29+
{
30+
assertType('array{}|array{0: string, 1: string, 2?: string}', $arr);
31+
if (count($arr) === 3) {
32+
assertType('array{string, string, string}', $arr);
33+
return;
34+
}
35+
// Fallback keeps full array{0: string, 1: string, 2?: string} since its size
36+
// range (2..3) is not fully contained in sizeType (3)
37+
assertType('array{}|array{0: string, 1: string, 2?: string}', $arr);
38+
}
39+
40+
/**
41+
* Test: IntegerRange sizeType fully covers optional key size range - array is correctly removed
42+
* @param array{}|array{0: string, 1: string, 2?: string} $arr
43+
* @param int<2, 3> $twoOrThree
44+
*/
45+
public function testIntRangeFullyCoveringOptionalKeys(array $arr, int $twoOrThree): void
46+
{
47+
if (count($arr) === $twoOrThree) {
48+
assertType('array{0: string, 1: string, 2?: string}', $arr);
49+
return;
50+
}
51+
// int<2,3> fully covers the optional-key array's size range (2..3),
52+
// so the array is correctly removed in the falsey branch
53+
assertType('array{}', $arr);
54+
}
55+
56+
/**
57+
* Test: IntegerRange partially covers optional key size range - array is kept
58+
* @param array{}|array{0: string, 1: string, 2?: string, 3?: string} $arr
59+
* @param int<2, 3> $twoOrThree
60+
*/
61+
public function testIntRangePartiallyCoveringOptionalKeys(array $arr, int $twoOrThree): void
62+
{
63+
if (count($arr) === $twoOrThree) {
64+
assertType('array{0: string, 1: string, 2?: string, 3?: string}', $arr);
65+
return;
66+
}
67+
// int<2,3> does NOT fully cover size range (2..4), so the array is kept
68+
assertType('array{}|array{0: string, 1: string, 2?: string, 3?: string}', $arr);
69+
}
70+
71+
/**
72+
* Test: IntegerRange sizeType with union of constant arrays including array{}
73+
* @param array{}|array{string}|array{string, string, string, string} $arr
74+
* @param int<2, 4> $twoToFour
75+
*/
76+
public function testIntRangeWithUnionAndEmpty(array $arr, int $twoToFour): void
77+
{
78+
if (count($arr) === $twoToFour) {
79+
assertType('array{string, string, string, string}', $arr);
80+
return;
81+
}
82+
assertType('array{}|array{string, string, string, string}|array{string}', $arr);
83+
}
84+
}
85+
86+
// Test: sequential count checks preserve narrowing correctly
87+
function () {
88+
preg_match('/^(.)$/', '', $m) || preg_match('/^(.)(.)(.)$/', '', $m) || preg_match('/^(.)(.)(.)(.)(.)(.)$/', '', $m);
89+
assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string}', $m);
90+
if (count($m) === 2) {
91+
assertType('array{non-falsy-string, non-empty-string}', $m);
92+
return;
93+
}
94+
assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $m);
95+
if (count($m) === 4) {
96+
assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $m);
97+
return;
98+
}
99+
assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}', $m);
100+
if (count($m) === 7) {
101+
assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string, non-empty-string}', $m);
102+
}
103+
};
104+
105+
// Test: count narrowing does not lose other variable types
106+
function (int $x) {
107+
preg_match('/^(.)$/', '', $matches) || preg_match('/^(.)(.)(.)$/', '', $matches);
108+
if ($x > 0) {
109+
assertType('int<1, max>', $x);
110+
if (count($matches) === 2) {
111+
assertType('array{non-falsy-string, non-empty-string}', $matches);
112+
assertType('int<1, max>', $x);
113+
return;
114+
}
115+
assertType('array{}|array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches);
116+
assertType('int<1, max>', $x);
117+
}
118+
};

0 commit comments

Comments
 (0)