diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php index fd0cacc17f..5814096332 100644 --- a/src/Type/Php/RegexArrayShapeMatcher.php +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -297,7 +297,8 @@ private function buildArrayType( $i = 0; foreach ($captureGroups as $captureGroup) { $isTrailingOptional = $i >= $countGroups - $trailingOptionals; - $groupValueType = $this->createGroupValueType($captureGroup, $wasMatched, $flags, $isTrailingOptional, $matchesAll); + $isLastGroup = $i === $countGroups - 1; + $groupValueType = $this->createGroupValueType($captureGroup, $wasMatched, $flags, $isTrailingOptional, $isLastGroup, $matchesAll); $optional = $this->isGroupOptional($captureGroup, $wasMatched, $flags, $isTrailingOptional, $matchesAll); if ($captureGroup->isNamed()) { @@ -390,11 +391,11 @@ private function isGroupOptional(RegexCapturingGroup $captureGroup, TrinaryLogic return $optional; } - private function createGroupValueType(RegexCapturingGroup $captureGroup, TrinaryLogic $wasMatched, int $flags, bool $isTrailingOptional, bool $matchesAll): Type + private function createGroupValueType(RegexCapturingGroup $captureGroup, TrinaryLogic $wasMatched, int $flags, bool $isTrailingOptional, bool $isLastGroup, bool $matchesAll): Type { - $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll); - if ($matchesAll) { + $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll); + if (!$isTrailingOptional && $this->containsUnmatchedAsNull($flags, $matchesAll) && !$captureGroup->isOptional()) { $groupValueType = TypeCombinator::removeNull($groupValueType); } @@ -411,16 +412,22 @@ private function createGroupValueType(RegexCapturingGroup $captureGroup, Trinary return $groupValueType; } + if (!$isLastGroup && !$this->containsUnmatchedAsNull($flags, $matchesAll) && $captureGroup->isOptional()) { + $groupValueType = $this->getValueType( + TypeCombinator::union($captureGroup->getType(), new ConstantStringType('')), + $flags, + $matchesAll, + ); + } else { + $groupValueType = $this->getValueType($captureGroup->getType(), $flags, $matchesAll); + } + if ($wasMatched->yes()) { if (!$isTrailingOptional && $this->containsUnmatchedAsNull($flags, $matchesAll) && !$captureGroup->isOptional()) { $groupValueType = TypeCombinator::removeNull($groupValueType); } } - if (!$isTrailingOptional && !$this->containsUnmatchedAsNull($flags, $matchesAll) && $captureGroup->isOptional()) { - $groupValueType = TypeCombinator::union($groupValueType, new ConstantStringType('')); - } - return $groupValueType; } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php b/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php index 51fc1a05be..40e6d99d9f 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php @@ -14,9 +14,9 @@ function doFoo(string $s) { function doUnmatchedAsNull(string $s): void { if (preg_match('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType("array{0: string, 1?: 'foo', 2?: 'bar', 3?: 'baz'}", $matches); + assertType("array{0: string, 1?: ''|'foo', 2?: ''|'bar', 3?: 'baz'}", $matches); } - assertType("array{}|array{0: string, 1?: 'foo', 2?: 'bar', 3?: 'baz'}", $matches); + assertType("array{}|array{0: string, 1?: ''|'foo', 2?: ''|'bar', 3?: 'baz'}", $matches); } // see https://3v4l.org/VeDob#veol diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index 9cb44dbd0c..39bdea3e68 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -300,7 +300,7 @@ function (string $size): void { if (preg_match('~^a\.(b)?(c)?d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType("array{0: string, 1?: 'b', 2?: 'c'}", $matches); + assertType("array{0: string, 1?: ''|'b', 2?: 'c'}", $matches); }; function (string $size): void { @@ -567,7 +567,7 @@ function (string $s): void { } if (preg_match($p, $s, $matches)) { - assertType("array{0: string, 1: non-empty-string, 2?: numeric-string, 3?: 'x'}", $matches); + assertType("array{0: string, 1: non-empty-string, 2?: ''|numeric-string, 3?: 'x'}", $matches); } }; @@ -662,3 +662,24 @@ function (string $value): void assertType("array{0: array{string, int<0, max>}, 1?: array{non-empty-string, int<0, max>}, 2?: array{non-empty-string, int<0, max>}}", $matches); } }; + +class Bug11479 +{ + static public function sayHello(string $source): void + { + $pattern = "~^(?P\d)?\-?(?P\d)?$~"; + + preg_match($pattern, $source, $matches); + + // for $source = "-1" in $matches is + // array ( + // 0 => '-1', + // 'dateFrom' => '', + // 1 => '', + // 'dateTo' => '1', + // 2 => '1', + //) + + assertType("array{0?: string, dateFrom?: ''|numeric-string, 1?: ''|numeric-string, dateTo?: numeric-string, 2?: numeric-string}", $matches); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php index 621051467e..9c44d355df 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php @@ -22,7 +22,7 @@ function (string $s): void { preg_replace_callback( '/(foo)?(bar)?(baz)?/', function ($matches) { - assertType("array{0: array{string, int<0, max>}, 1?: array{'foo', int<0, max>}, 2?: array{'bar', int<0, max>}, 3?: array{'baz', int<0, max>}}", $matches); + assertType("array{0: array{string, int<0, max>}, 1?: array{''|'foo', int<0, max>}, 2?: array{''|'bar', int<0, max>}, 3?: array{'baz', int<0, max>}}", $matches); return ''; }, $s,