diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php index d2fcc02336..ec7c2f51d0 100644 --- a/src/Type/Php/RegexArrayShapeMatcher.php +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -271,7 +271,7 @@ private function buildArrayType( } else { if ($i < $countGroups - $trailingOptionals) { $optional = false; - if ($this->containsUnmatchedAsNull($flags)) { + if ($this->containsUnmatchedAsNull($flags) && !$captureGroup->isOptional()) { $groupValueType = TypeCombinator::removeNull($groupValueType); } } elseif ($this->containsUnmatchedAsNull($flags)) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php index 8db253bf7d..7d5a8d0c80 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php @@ -29,3 +29,39 @@ function unmatchedAsNullWithOptionalGroup(string $s): void { assertType('array{}|array{string, string|null}', $matches); } +function bug11331a(string $url):void { + // group a is actually optional as the entire (?:...) around it is optional + if (preg_match('{^ + (?: + (?.+) + )? + (?.+)}mix', $url, $matches, PREG_UNMATCHED_AS_NULL)) { + assertType('array{0: string, a: string|null, 1: string|null, b: string, 2: string}', $matches); + } +} + +function bug11331b(string $url):void { + if (preg_match('{^ + (?: + (?.+) + )? + (?.+)?}mix', $url, $matches, PREG_UNMATCHED_AS_NULL)) { + assertType('array{0: string, a: string|null, 1: string|null, b: string|null, 2: string|null}', $matches); + } +} + +function bug11331c(string $url):void { + if (preg_match('{^ + (?: + (?:https?|git)://([^/]+)/ (?# group 1 here can be null if group 2 matches) + | (?# the alternation making it so that only either should match) + git@([^:]+):/? (?# group 2 here can be null if group 1 matches) + ) + ([^/]+) + / + ([^/]+?) + (?:\.git|/)? +$}x', $url, $matches, PREG_UNMATCHED_AS_NULL)) { + assertType('array{string, string|null, string|null, string, string}', $matches); + } +}