diff --git a/src/Type/Regex/RegexExpressionHelper.php b/src/Type/Regex/RegexExpressionHelper.php index 2b94fda6e2..0334aead77 100644 --- a/src/Type/Regex/RegexExpressionHelper.php +++ b/src/Type/Regex/RegexExpressionHelper.php @@ -11,6 +11,7 @@ use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use function array_key_exists; +use function ltrim; use function strrpos; use function substr; @@ -147,6 +148,8 @@ public function getPatternDelimiters(Concat $concat, Scope $scope): array private function getPatternDelimiter(string $regex): ?string { + $regex = ltrim($regex); + if ($regex === '') { return null; } diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index 6938ff8000..8f282899ed 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -20,6 +20,7 @@ use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_merge; use function count; use function in_array; use function is_int; @@ -432,7 +433,7 @@ private function walkGroupAst( $isNonEmpty = TrinaryLogic::createYes(); } } - } elseif (!in_array($ast->getId(), ['#capturing', '#namedcapturing'], true)) { + } elseif (!in_array($ast->getId(), ['#capturing', '#namedcapturing', '#alternation'], true)) { $onlyLiterals = null; } @@ -447,6 +448,7 @@ private function walkGroupAst( $isNumeric = TrinaryLogic::createNo(); } + $alternativeLiterals = []; foreach ($children as $child) { $this->walkGroupAst( $child, @@ -459,7 +461,24 @@ private function walkGroupAst( $inClass, $patternModifiers, ); + + if ($ast->getId() !== '#alternation') { + continue; + } + + if ($onlyLiterals !== null && $alternativeLiterals !== null) { + $alternativeLiterals = array_merge($alternativeLiterals, $onlyLiterals); + $onlyLiterals = []; + } else { + $alternativeLiterals = null; + } } + + if ($alternativeLiterals === null || $alternativeLiterals === []) { + return; + } + + $onlyLiterals = $alternativeLiterals; } private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy): bool diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php b/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php index 40e6d99d9f..957b3557de 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php @@ -22,9 +22,9 @@ function doUnmatchedAsNull(string $s): void { // 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?: non-empty-string}", $matches); + assertType("array{0: string, 1?: '£'|'€'}", $matches); } else { assertType('array{}', $matches); } - assertType("array{}|array{0: string, 1?: non-empty-string}", $matches); + assertType("array{}|array{0: string, 1?: '£'|'€'}", $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php index 3a01594ded..0d2eaec66d 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php @@ -23,11 +23,11 @@ function doUnmatchedAsNull(string $s): void { 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, non-empty-string|null}', $matches); + assertType("array{string, '£'|'€'|null}", $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{string, non-empty-string|null}', $matches); + assertType("array{}|array{string, '£'|'€'|null}", $matches); } function bug11331a(string $url):void { diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index 001ec26d21..655c9d7d10 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -12,11 +12,11 @@ function doMatch(string $s): void { assertType('array{}|array{string}', $matches); if (preg_match('/Price: (£|€)\d+/', $s, $matches)) { - assertType('array{string, non-empty-string}', $matches); + assertType("array{string, '£'|'€'}", $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{string, non-empty-string}', $matches); + assertType("array{}|array{string, '£'|'€'}", $matches); if (preg_match('/Price: (£|€)(\d+)/i', $s, $matches)) { assertType('array{string, non-empty-string, numeric-string}', $matches); @@ -54,9 +54,9 @@ function doMatch(string $s): void { assertType("array{}|array{0: string, 1: 'a', 2: string, 3: 'c', name?: non-empty-string, 4?: non-empty-string}", $matches); if (preg_match('/(a|b)|(?:c)/', $s, $matches)) { - assertType('array{0: string, 1?: non-empty-string}', $matches); + assertType("array{0: string, 1?: 'a'|'b'}", $matches); } - assertType('array{}|array{0: string, 1?: non-empty-string}', $matches); + assertType("array{}|array{0: string, 1?: 'a'|'b'}", $matches); if (preg_match('/(foo)(bar)(baz)+/', $s, $matches)) { assertType("array{string, 'foo', 'bar', non-falsy-string}", $matches); @@ -356,30 +356,30 @@ function bug11291(string $s): void { function bug11323a(string $s): void { if (preg_match('/Price: (?P£|€)\d+/', $s, $matches)) { - assertType('array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); + assertType("array{0: string, currency: '£'|'€', 1: '£'|'€'}", $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); + assertType("array{}|array{0: string, currency: '£'|'€', 1: '£'|'€'}", $matches); } function bug11323b(string $s): void { if (preg_match('/Price: (?£|€)\d+/', $s, $matches)) { - assertType('array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); + assertType("array{0: string, currency: '£'|'€', 1: '£'|'€'}", $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); + assertType("array{}|array{0: string, currency: '£'|'€', 1: '£'|'€'}", $matches); } function unmatchedAsNullWithMandatoryGroup(string $s): void { if (preg_match('/Price: (?£|€)\d+/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); + assertType("array{0: string, currency: '£'|'€', 1: '£'|'€'}", $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); + assertType("array{}|array{0: string, currency: '£'|'€', 1: '£'|'€'}", $matches); } function (string $s): void { @@ -608,17 +608,29 @@ function (string $s): void { }; function (string $s): void { - if (preg_match('/Price: (a|0)/', $s, $matches)) { + if (preg_match('/Price: (a|bc?)/', $s, $matches)) { assertType("array{string, non-empty-string}", $matches); } }; function (string $s): void { - if (preg_match('/Price: (aa|0)/', $s, $matches)) { + if (preg_match('/Price: (a|\d)/', $s, $matches)) { assertType("array{string, non-empty-string}", $matches); } }; +function (string $s): void { + if (preg_match('/Price: (a|0)/', $s, $matches)) { + assertType("array{string, '0'|'a'}", $matches); + } +}; + +function (string $s): void { + if (preg_match('/Price: (aa|0)/', $s, $matches)) { + assertType("array{string, '0'|'aa'}", $matches); + } +}; + function (string $s): void { if (preg_match('/( \d+ )/x', $s, $matches)) { assertType('array{string, numeric-string}', $matches);