From f9960fed6ee67ae0af81e40bc44207c033e15bb3 Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:53:00 +0000 Subject: [PATCH 1/3] Narrow regex subject to `decimal-int-string` when every alternation branch is a decimal integer - `RegexGroupParser::walkGroupAst()` combined the running decimal-integer state with an alternation result using `TrinaryLogic::and()`. With the initial "maybe" state this swallowed an all-decimal alternation (`maybe->and(yes) === maybe`), so patterns like `/^(?:0|-?[1-9][0-9]*)$/` narrowed the `preg_match()` subject only to `non-empty-string`. - Introduce `concatDecimalInteger()` which mirrors the per-token logic: a non-decimal part forces `no`, a decimal part forces `yes`, otherwise `maybe`. This fixes the subject base type and the whole-match group used by `preg_match`/`preg_match_all`. - Works for the capturing-group variant, alternations without a leading sign, alternations of decimal literals, and nested alternations. --- src/Type/Regex/RegexGroupParser.php | 23 +++++++++++- tests/PHPStan/Analyser/nsrt/bug-14784.php | 43 +++++++++++++++++++++++ 2 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14784.php diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index 5d8e2d7bfd..2d537651c9 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -617,7 +617,7 @@ private function walkGroupAst( ->onlyLiterals($newLiterals) ->nonEmpty($walkResult->isNonEmpty()->or($nonEmpty)) ->nonFalsy($walkResult->isNonFalsy()->or($nonFalsy)) - ->decimalInteger($walkResult->isDecimalInteger()->and($decimalInteger)); + ->decimalInteger($this->concatDecimalInteger($walkResult->isDecimalInteger(), $decimalInteger)); } // [^0-9] should not parse as decimal-int-string, and [^list-everything-but-numbers] is technically @@ -639,6 +639,27 @@ private function walkGroupAst( return $walkResult; } + /** + * Combines the running decimal-integer state with an appended part (e.g. an alternation). + * + * Mirrors the per-token logic: a non-decimal part makes the whole string non-decimal, while a + * decimal part forces the whole string to a decimal integer. This cannot be expressed with a + * plain `and()` because the initial "maybe" state would swallow a decimal part instead of + * committing to "yes". + */ + private function concatDecimalInteger(TrinaryLogic $left, TrinaryLogic $right): TrinaryLogic + { + if ($left->no() || $right->no()) { + return TrinaryLogic::createNo(); + } + + if ($left->yes() || $right->yes()) { + return TrinaryLogic::createYes(); + } + + return TrinaryLogic::createMaybe(); + } + private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy, bool &$isNonDecimal): bool { if ($node->getId() === '#quantification') { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14784.php b/tests/PHPStan/Analyser/nsrt/bug-14784.php new file mode 100644 index 0000000000..8bed549015 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14784.php @@ -0,0 +1,43 @@ + Date: Sat, 6 Jun 2026 15:59:09 +0200 Subject: [PATCH 2/3] simplify --- src/Type/Regex/RegexGroupParser.php | 23 +---------------------- 1 file changed, 1 insertion(+), 22 deletions(-) diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index 2d537651c9..5bf632b825 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -617,7 +617,7 @@ private function walkGroupAst( ->onlyLiterals($newLiterals) ->nonEmpty($walkResult->isNonEmpty()->or($nonEmpty)) ->nonFalsy($walkResult->isNonFalsy()->or($nonFalsy)) - ->decimalInteger($this->concatDecimalInteger($walkResult->isDecimalInteger(), $decimalInteger)); + ->decimalInteger(TrinaryLogic::maxMin($walkResult->isDecimalInteger(), $decimalInteger)); } // [^0-9] should not parse as decimal-int-string, and [^list-everything-but-numbers] is technically @@ -639,27 +639,6 @@ private function walkGroupAst( return $walkResult; } - /** - * Combines the running decimal-integer state with an appended part (e.g. an alternation). - * - * Mirrors the per-token logic: a non-decimal part makes the whole string non-decimal, while a - * decimal part forces the whole string to a decimal integer. This cannot be expressed with a - * plain `and()` because the initial "maybe" state would swallow a decimal part instead of - * committing to "yes". - */ - private function concatDecimalInteger(TrinaryLogic $left, TrinaryLogic $right): TrinaryLogic - { - if ($left->no() || $right->no()) { - return TrinaryLogic::createNo(); - } - - if ($left->yes() || $right->yes()) { - return TrinaryLogic::createYes(); - } - - return TrinaryLogic::createMaybe(); - } - private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy, bool &$isNonDecimal): bool { if ($node->getId() === '#quantification') { From 68c4ec4e253acba689c65a15a191a8c85d282df1 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 6 Jun 2026 16:17:21 +0200 Subject: [PATCH 3/3] add non-falsy-string test variant --- tests/PHPStan/Analyser/nsrt/bug-14784.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14784.php b/tests/PHPStan/Analyser/nsrt/bug-14784.php index 8bed549015..581bc14195 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14784.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14784.php @@ -35,6 +35,9 @@ function doFoo(string $str): void if (preg_match('/^(?:0|abc)$/', $str)) { assertType('non-empty-string', $str); } + if (preg_match('/^(?:2|abc)$/', $str)) { + assertType('non-falsy-string', $str); + } // a capturing decimal alternation keeps the group decimal too if (preg_match('/^(0|[1-9][0-9]*)$/', $str, $matches)) {