diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php index 794579695f..0facf36b1a 100644 --- a/src/Type/Php/RegexArrayShapeMatcher.php +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -121,10 +121,13 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched $flags ?? 0, ); - return TypeCombinator::union( - new ConstantArrayType([new ConstantIntegerType(0)], [new StringType()], [0], [], true), - $combiType, - ); + if (!$this->containsUnmatchedAsNull($flags ?? 0)) { + $combiType = TypeCombinator::union( + new ConstantArrayType([new ConstantIntegerType(0)], [new StringType()], [0], [], true), + $combiType, + ); + } + return $combiType; } elseif ( $wasMatched->yes() && $onlyTopLevelAlternationId !== null diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php b/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php index 5acaade1e6..6e768b5c01 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php @@ -19,3 +19,12 @@ function doUnmatchedAsNull(string $s): void { assertType('array{}|array{0: string, 1?: string, 2?: string, 3?: string}', $matches); } +// see https://3v4l.org/VeDob#veol +function unmatchedAsNullWithOptionalGroup(string $s): void { + if (preg_match('/Price: (£|€)?\d+/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { + assertType('array{0: string, 1?: string}', $matches); + } else { + assertType('array{}', $matches); + } + assertType('array{}|array{0: string, 1?: string}', $matches); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php index a52044feb7..8db253bf7d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php @@ -17,3 +17,15 @@ function doUnmatchedAsNull(string $s): void { } assertType('array{}|array{string, string|null, string|null, string|null}', $matches); } + +// see https://3v4l.org/VeDob +function unmatchedAsNullWithOptionalGroup(string $s): void { + if (preg_match('/Price: (£|€)?\d+/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { + // with PREG_UNMATCHED_AS_NULL the offset 1 will always exist. It is correct that it's nullable because it's optional though + assertType('array{string, string|null}', $matches); + } else { + assertType('array{}', $matches); + } + assertType('array{}|array{string, string|null}', $matches); +} + diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index 768787194d..2b1b774a48 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -383,3 +383,13 @@ function bug11323b(string $s): void } assertType('array{}|array{0: string, currency: string, 1: string}', $matches); } + +function unmatchedAsNullWithMandatoryGroup(string $s): void { + if (preg_match('/Price: (?£|€)\d+/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { + assertType('array{0: string, currency: string, 1: string}', $matches); + } else { + assertType('array{}', $matches); + } + assertType('array{}|array{0: string, currency: string, 1: string}', $matches); +} +