Skip to content

Commit 66a7578

Browse files
authored
RegexArrayShapeMatcher - trailling groups are not optional when PREG_UNMATCHED_AS_NULL
1 parent 10ae71d commit 66a7578

File tree

5 files changed

+73
-10
lines changed

5 files changed

+73
-10
lines changed

src/Php/PhpVersion.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,13 @@ public function supportsNeverReturnTypeInArrowFunction(): bool
309309
return $this->versionId >= 80200;
310310
}
311311

312+
public function supportsPregUnmatchedAsNull(): bool
313+
{
314+
// while PREG_UNMATCHED_AS_NULL is defined in php-src since 7.2.x it starts working as expected with 7.4.x
315+
// https://3v4l.org/v3HE4
316+
return $this->versionId >= 70400;
317+
}
318+
312319
public function hasDateTimeExceptions(): bool
313320
{
314321
return $this->versionId >= 80300;

src/Type/Php/RegexArrayShapeMatcher.php

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Hoa\Compiler\Llk\TreeNode;
88
use Hoa\Exception\Exception;
99
use Hoa\File\Read;
10+
use PHPStan\Php\PhpVersion;
1011
use PHPStan\TrinaryLogic;
1112
use PHPStan\Type\Constant\ConstantArrayType;
1213
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
@@ -33,6 +34,12 @@ final class RegexArrayShapeMatcher
3334

3435
private static ?Parser $parser = null;
3536

37+
public function __construct(
38+
private PhpVersion $phpVersion,
39+
)
40+
{
41+
}
42+
3643
public function matchType(Type $patternType, ?Type $flagsType, TrinaryLogic $wasMatched): ?Type
3744
{
3845
if ($wasMatched->no()) {
@@ -111,6 +118,7 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched
111118
$valueType,
112119
$wasMatched,
113120
$trailingOptionals,
121+
$flags ?? 0,
114122
);
115123

116124
return TypeCombinator::union(
@@ -145,6 +153,7 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched
145153
$valueType,
146154
$wasMatched,
147155
$trailingOptionals,
156+
$flags ?? 0,
148157
);
149158

150159
$combiTypes[] = $combiType;
@@ -167,6 +176,7 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched
167176
$valueType,
168177
$wasMatched,
169178
$trailingOptionals,
179+
$flags ?? 0,
170180
);
171181
}
172182

@@ -228,6 +238,7 @@ private function buildArrayType(
228238
Type $valueType,
229239
TrinaryLogic $wasMatched,
230240
int $trailingOptionals,
241+
int $flags,
231242
): Type
232243
{
233244
$builder = ConstantArrayTypeBuilder::createEmpty();
@@ -242,11 +253,18 @@ private function buildArrayType(
242253
$countGroups = count($captureGroups);
243254
$i = 0;
244255
foreach ($captureGroups as $captureGroup) {
256+
$groupValueType = $valueType;
257+
245258
if (!$wasMatched->yes()) {
246259
$optional = true;
247260
} else {
248261
if ($i < $countGroups - $trailingOptionals) {
249262
$optional = false;
263+
if ($this->containsUnmatchedAsNull($flags)) {
264+
$groupValueType = TypeCombinator::removeNull($groupValueType);
265+
}
266+
} elseif ($this->containsUnmatchedAsNull($flags)) {
267+
$optional = false;
250268
} else {
251269
$optional = $captureGroup->isOptional();
252270
}
@@ -255,14 +273,14 @@ private function buildArrayType(
255273
if ($captureGroup->isNamed()) {
256274
$builder->setOffsetValueType(
257275
$this->getKeyType($captureGroup->getName()),
258-
$valueType,
276+
$groupValueType,
259277
$optional,
260278
);
261279
}
262280

263281
$builder->setOffsetValueType(
264282
$this->getKeyType($i + 1),
265-
$valueType,
283+
$groupValueType,
266284
$optional,
267285
);
268286

@@ -272,6 +290,11 @@ private function buildArrayType(
272290
return $builder->getArray();
273291
}
274292

293+
private function containsUnmatchedAsNull(int $flags): bool
294+
{
295+
return ($flags & PREG_UNMATCHED_AS_NULL) !== 0 && $this->phpVersion->supportsPregUnmatchedAsNull();
296+
}
297+
275298
private function getKeyType(int|string $key): Type
276299
{
277300
if (is_string($key)) {
@@ -285,7 +308,7 @@ private function getValueType(int $flags): Type
285308
{
286309
$valueType = new StringType();
287310
$offsetType = IntegerRangeType::fromInterval(0, null);
288-
if (($flags & PREG_UNMATCHED_AS_NULL) !== 0) {
311+
if ($this->containsUnmatchedAsNull($flags)) {
289312
$valueType = TypeCombinator::addNull($valueType);
290313
// unmatched groups return -1 as offset
291314
$offsetType = IntegerRangeType::fromInterval(-1, null);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php // lint < 7.4
2+
3+
namespace Bug11311Php72;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
// on PHP < 7.4, unmatched-as-null does not return null values; see https://3v4l.org/v3HE4
8+
9+
function doFoo(string $s) {
10+
if (1 === preg_match('/(?<major>\d+)\.(?<minor>\d+)(?:\.(?<patch>\d+))?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) {
11+
assertType('array{0: string, major: string, 1: string, minor: string, 2: string, patch?: string, 3?: string}', $matches);
12+
}
13+
}
14+
15+
function doUnmatchedAsNull(string $s): void {
16+
if (preg_match('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) {
17+
assertType('array{0: string, 1?: string, 2?: string, 3?: string}', $matches);
18+
}
19+
assertType('array{}|array{0: string, 1?: string, 2?: string, 3?: string}', $matches);
20+
}
21+
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php // lint >= 7.4
2+
3+
namespace Bug11311;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function doFoo(string $s) {
8+
if (1 === preg_match('/(?<major>\d+)\.(?<minor>\d+)(?:\.(?<patch>\d+))?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) {
9+
10+
assertType('array{0: string, major: string, 1: string, minor: string, 2: string, patch: string|null, 3: string|null}', $matches);
11+
}
12+
}
13+
14+
function doUnmatchedAsNull(string $s): void {
15+
if (preg_match('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) {
16+
assertType('array{string, string|null, string|null, string|null}', $matches);
17+
}
18+
assertType('array{}|array{string, string|null, string|null, string|null}', $matches);
19+
}

tests/PHPStan/Analyser/nsrt/preg_match_shapes.php

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -116,13 +116,6 @@ function doOffsetCapture(string $s): void {
116116
assertType('array{}|array{array{string, int<0, max>}, array{string, int<0, max>}, array{string, int<0, max>}, array{string, int<0, max>}}', $matches);
117117
}
118118

119-
function doUnmatchedAsNull(string $s): void {
120-
if (preg_match('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) {
121-
assertType('array{0: string, 1?: string|null, 2?: string|null, 3?: string|null}', $matches);
122-
}
123-
assertType('array{}|array{0: string, 1?: string|null, 2?: string|null, 3?: string|null}', $matches);
124-
}
125-
126119
function doUnknownFlags(string $s, int $flags): void {
127120
if (preg_match('/(foo)(bar)(baz)/xyz', $s, $matches, $flags)) {
128121
assertType('array<array{string|null, int<-1, max>}|string|null>', $matches);

0 commit comments

Comments
 (0)