From 31e7143dc61b0272b3a144bcf403dab576ec9f6f Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 2 Jun 2026 15:01:33 +0000 Subject: [PATCH 1/7] Infer `decimal-int-string`/`non-decimal-int-string` for regex capturing groups matching (non-)digits - `RegexGroupParser` now emits `AccessoryDecimalIntegerStringType` for capturing groups that match only digits (e.g. `\d+`, `[0-9]+`, `[3-9]`), replacing the previous `numeric-string` inference, consistent with the recent `ctype_digit()` narrowing. - A single leading minus sign is tracked positionally so `-?[0-9]+` / `-[0-9]+` stay decimal integers, while a non-leading `-` (`\d+-\d+`) or a `+` (`[+-]?\d+`) correctly disqualifies the decimal-integer inference. - Negated character classes that exclude every digit (`[^0-9]`, `[^\d]`) now yield `non-decimal-int-string`, since they can only match non-digit characters. Negated classes that do not exclude all digits (`[^1-4]`) stay unrefined. - `RegexGroupWalkResult` replaces `isNumeric` with `isDecimalInteger`, adds `isNonDecimalInteger` and a leading-sign flag, all combined across alternations. - Updated existing preg shape tests where digit captures now infer `decimal-int-string`. - Subject-type narrowing of the matched string itself (`preg_match('/^\d+$/', $x)` narrowing `$x`) is intentionally left out; it requires full-anchor detection and is tracked separately. --- src/Type/Regex/RegexGroupParser.php | 113 +++++++++++++++--- src/Type/Regex/RegexGroupWalkResult.php | 70 +++++++++-- tests/PHPStan/Analyser/nsrt/bug-11293.php | 18 +-- tests/PHPStan/Analyser/nsrt/bug-11311.php | 30 ++--- tests/PHPStan/Analyser/nsrt/bug-14177.php | 8 +- tests/PHPStan/Analyser/nsrt/bug-14750.php | 66 ++++++++++ .../Analyser/nsrt/preg_match_all_shapes.php | 54 ++++----- .../Analyser/nsrt/preg_match_shapes.php | 106 ++++++++-------- .../Analyser/nsrt/preg_match_shapes_php80.php | 4 +- .../Analyser/nsrt/preg_match_shapes_php82.php | 16 +-- 10 files changed, 342 insertions(+), 143 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14750.php diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index e2ab45d31d1..dc7466f2c94 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -13,9 +13,9 @@ use PHPStan\Php\PhpVersion; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryDecimalIntegerStringType; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; -use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; @@ -24,9 +24,11 @@ use PHPStan\Type\UnionType; use function array_key_exists; use function array_values; +use function chr; use function count; use function in_array; use function is_int; +use function ord; use function preg_replace; use function rtrim; use function sscanf; @@ -420,20 +422,29 @@ private function createGroupType(TreeNode $group, bool $maybeConstant, string $p return TypeCombinator::union(...$result); } - if ($walkResult->isNumeric()->yes()) { + if ($walkResult->isDecimalInteger()->yes()) { if ($walkResult->isNonFalsy()->yes()) { return new IntersectionType([ new StringType(), - new AccessoryNumericStringType(), + new AccessoryDecimalIntegerStringType(), new AccessoryNonFalsyStringType(), ]); } - $result = new IntersectionType([new StringType(), new AccessoryNumericStringType()]); + $result = new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]); if (!$walkResult->isNonEmpty()->yes()) { return new UnionType([new ConstantStringType(''), $result]); } return $result; + } elseif ($walkResult->isNonDecimalInteger()->yes()) { + $accessories = [new StringType(), new AccessoryDecimalIntegerStringType(true)]; + if ($walkResult->isNonFalsy()->yes()) { + $accessories[] = new AccessoryNonFalsyStringType(); + } elseif ($walkResult->isNonEmpty()->yes()) { + $accessories[] = new AccessoryNonEmptyStringType(); + } + + return new IntersectionType($accessories); } elseif ($walkResult->isNonFalsy()->yes()) { return new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]); } elseif ($walkResult->isNonEmpty()->yes()) { @@ -470,6 +481,7 @@ private function walkGroupAst( bool $inClass, string $patternModifiers, RegexGroupWalkResult $walkResult, + bool $inNegativeClass = false, ): RegexGroupWalkResult { $children = $ast->getChildren(); @@ -541,10 +553,27 @@ private function walkGroupAst( $walkResult = $walkResult->onlyLiterals($onlyLiterals); if ($literalValue !== null) { - if (Strings::match($literalValue, '/^\d+$/') === null) { - $walkResult = $walkResult->numeric(TrinaryLogic::createNo()); - } elseif ($walkResult->isNumeric()->maybe()) { - $walkResult = $walkResult->numeric(TrinaryLogic::createYes()); + if (!$inNegativeClass) { + if (Strings::match($literalValue, '/^\d+$/') !== null) { + if ($walkResult->isDecimalInteger()->maybe()) { + $walkResult = $walkResult->decimalInteger(TrinaryLogic::createYes()); + } + } elseif ( + $literalValue === '-' + && $walkResult->isDecimalInteger()->maybe() + && !$walkResult->hasSeenDecimalIntegerSign() + ) { + // a single leading minus sign keeps the string a decimal integer (e.g. "-1") + $walkResult = $walkResult->seenDecimalIntegerSign(true); + } else { + $walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo()); + } + + // a literal token outside a negative class might be (part of) a decimal integer, + // so we can no longer guarantee the absence of one + if ($literalValue !== '') { + $walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createNo()); + } } if (!$walkResult->isInOptionalQuantification() && $literalValue !== '') { @@ -563,7 +592,8 @@ private function walkGroupAst( $newLiterals = []; $nonEmpty = TrinaryLogic::createYes(); $nonFalsy = TrinaryLogic::createYes(); - $numeric = TrinaryLogic::createYes(); + $decimalInteger = TrinaryLogic::createYes(); + $nonDecimalInteger = TrinaryLogic::createYes(); foreach ($children as $child) { $childResult = $this->walkGroupAst( $child, @@ -572,12 +602,16 @@ private function walkGroupAst( $walkResult->onlyLiterals([]) ->nonEmpty(TrinaryLogic::createMaybe()) ->nonFalsy(TrinaryLogic::createMaybe()) - ->numeric(TrinaryLogic::createMaybe()), + ->decimalInteger(TrinaryLogic::createMaybe()) + ->nonDecimalInteger(TrinaryLogic::createMaybe()) + ->seenDecimalIntegerSign(false), + $inNegativeClass, ); $nonEmpty = $nonEmpty->and($childResult->isNonEmpty()); $nonFalsy = $nonFalsy->and($childResult->isNonFalsy()); - $numeric = $numeric->and($childResult->isNumeric()); + $decimalInteger = $decimalInteger->and($childResult->isDecimalInteger()); + $nonDecimalInteger = $nonDecimalInteger->and($childResult->isNonDecimalInteger()); if ($newLiterals === null) { continue; @@ -596,14 +630,23 @@ private function walkGroupAst( ->onlyLiterals($newLiterals) ->nonEmpty($walkResult->isNonEmpty()->or($nonEmpty)) ->nonFalsy($walkResult->isNonFalsy()->or($nonFalsy)) - ->numeric($walkResult->isNumeric()->and($numeric)); + ->decimalInteger($walkResult->isDecimalInteger()->and($decimalInteger)) + ->nonDecimalInteger($walkResult->isNonDecimalInteger()->and($nonDecimalInteger)); } - // [^0-9] should not parse as numeric-string, and [^list-everything-but-numbers] is technically - // doable but really silly compared to just \d so we can safely assume the string is not numeric - // for negative classes + // a negative class never matches a decimal integer on its own; when it excludes every + // digit it can only match non-digit characters, so the result is a non-decimal-int-string if ($ast->getId() === '#negativeclass') { - $walkResult = $walkResult->numeric(TrinaryLogic::createNo()); + $walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo()); + if ($this->negatedClassExcludesAllDigits($ast)) { + if ($walkResult->isNonDecimalInteger()->maybe()) { + $walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createYes()); + } + } else { + $walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createNo()); + } + + $inNegativeClass = true; } foreach ($children as $child) { @@ -612,12 +655,50 @@ private function walkGroupAst( $inClass, $patternModifiers, $walkResult, + $inNegativeClass, ); } return $walkResult; } + private function negatedClassExcludesAllDigits(TreeNode $negativeClass): bool + { + $excludedDigits = []; + foreach ($negativeClass->getChildren() as $child) { + if ($child->getId() === '#range') { + $from = $child->getChild(0)->getValueValue(); + $to = $child->getChild(1)->getValueValue(); + if (strlen($from) === 1 && strlen($to) === 1) { + for ($ord = ord($from); $ord <= ord($to); $ord++) { + $char = chr($ord); + if (Strings::match($char, '/^\d$/') === null) { + continue; + } + $excludedDigits[$char] = true; + } + } + } elseif ($child->getId() === 'token') { + $value = $child->getValueValue(); + if ($child->getValueToken() === 'character_type' && $value === '\d') { + for ($digit = 0; $digit <= 9; $digit++) { + $excludedDigits[(string) $digit] = true; + } + } elseif (Strings::match($value, '/^\d$/') !== null) { + $excludedDigits[$value] = true; + } + } + } + + for ($digit = 0; $digit <= 9; $digit++) { + if (!array_key_exists((string) $digit, $excludedDigits)) { + return false; + } + } + + return true; + } + private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy): bool { if ($node->getId() === '#quantification') { diff --git a/src/Type/Regex/RegexGroupWalkResult.php b/src/Type/Regex/RegexGroupWalkResult.php index 9169af89ba5..ecbf6fc53ab 100644 --- a/src/Type/Regex/RegexGroupWalkResult.php +++ b/src/Type/Regex/RegexGroupWalkResult.php @@ -16,7 +16,9 @@ public function __construct( private ?array $onlyLiterals, private TrinaryLogic $isNonEmpty, private TrinaryLogic $isNonFalsy, - private TrinaryLogic $isNumeric, + private TrinaryLogic $isDecimalInteger, + private TrinaryLogic $isNonDecimalInteger, + private bool $seenDecimalIntegerSign, ) { } @@ -29,6 +31,8 @@ public static function createEmpty(): self TrinaryLogic::createMaybe(), TrinaryLogic::createMaybe(), TrinaryLogic::createMaybe(), + TrinaryLogic::createMaybe(), + false, ); } @@ -39,7 +43,9 @@ public function inOptionalQuantification(bool $inOptionalQuantification): self $this->onlyLiterals, $this->isNonEmpty, $this->isNonFalsy, - $this->isNumeric, + $this->isDecimalInteger, + $this->isNonDecimalInteger, + $this->seenDecimalIntegerSign, ); } @@ -53,7 +59,9 @@ public function onlyLiterals(?array $onlyLiterals): self $onlyLiterals, $this->isNonEmpty, $this->isNonFalsy, - $this->isNumeric, + $this->isDecimalInteger, + $this->isNonDecimalInteger, + $this->seenDecimalIntegerSign, ); } @@ -64,7 +72,9 @@ public function nonEmpty(TrinaryLogic $nonEmpty): self $this->onlyLiterals, $nonEmpty, $this->isNonFalsy, - $this->isNumeric, + $this->isDecimalInteger, + $this->isNonDecimalInteger, + $this->seenDecimalIntegerSign, ); } @@ -75,18 +85,50 @@ public function nonFalsy(TrinaryLogic $nonFalsy): self $this->onlyLiterals, $this->isNonEmpty, $nonFalsy, - $this->isNumeric, + $this->isDecimalInteger, + $this->isNonDecimalInteger, + $this->seenDecimalIntegerSign, + ); + } + + /** A decimal integer string is composed only of digits, optionally preceded by a single leading minus sign. */ + public function decimalInteger(TrinaryLogic $decimalInteger): self + { + return new self( + $this->inOptionalQuantification, + $this->onlyLiterals, + $this->isNonEmpty, + $this->isNonFalsy, + $decimalInteger, + $this->isNonDecimalInteger, + $this->seenDecimalIntegerSign, + ); + } + + /** A non-decimal integer string is guaranteed to contain no decimal integer (e.g. it has no digits at all). */ + public function nonDecimalInteger(TrinaryLogic $nonDecimalInteger): self + { + return new self( + $this->inOptionalQuantification, + $this->onlyLiterals, + $this->isNonEmpty, + $this->isNonFalsy, + $this->isDecimalInteger, + $nonDecimalInteger, + $this->seenDecimalIntegerSign, ); } - public function numeric(TrinaryLogic $numeric): self + public function seenDecimalIntegerSign(bool $seenDecimalIntegerSign): self { return new self( $this->inOptionalQuantification, $this->onlyLiterals, $this->isNonEmpty, $this->isNonFalsy, - $numeric, + $this->isDecimalInteger, + $this->isNonDecimalInteger, + $seenDecimalIntegerSign, ); } @@ -127,9 +169,19 @@ public function isNonFalsy(): TrinaryLogic return $this->isNonFalsy; } - public function isNumeric(): TrinaryLogic + public function isDecimalInteger(): TrinaryLogic + { + return $this->isDecimalInteger; + } + + public function isNonDecimalInteger(): TrinaryLogic + { + return $this->isNonDecimalInteger; + } + + public function hasSeenDecimalIntegerSign(): bool { - return $this->isNumeric; + return $this->seenDecimalIntegerSign; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11293.php b/tests/PHPStan/Analyser/nsrt/bug-11293.php index 49f8d008a6e..3014ef61dde 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11293.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11293.php @@ -9,54 +9,54 @@ class HelloWorld public function sayHello(string $s): void { if (preg_match('/data-(\d{6})\.json$/', $s, $matches) > 0) { - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); } } public function sayHello2(string $s): void { if (preg_match('/data-(\d{6})\.json$/', $s, $matches) === 1) { - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); } } public function sayHello3(string $s): void { if (preg_match('/data-(\d{6})\.json$/', $s, $matches) >= 1) { - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); } } public function sayHello4(string $s): void { if (preg_match('/data-(\d{6})\.json$/', $s, $matches) <= 0) { - assertType('array{}|array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); return; } - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); } public function sayHello5(string $s): void { if (preg_match('/data-(\d{6})\.json$/', $s, $matches) < 1) { - assertType('array{}|array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); return; } - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); } public function sayHello6(string $s): void { if (1 > preg_match('/data-(\d{6})\.json$/', $s, $matches)) { - assertType('array{}|array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); return; } - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php index 79fbf3aca44..897375a89d3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php @@ -8,7 +8,7 @@ function doFoo(string $s) { if (1 === preg_match('/(?\d+)\.(?\d+)(?:\.(?\d+))?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{0: non-falsy-string, major: numeric-string, 1: numeric-string, minor: numeric-string, 2: numeric-string, patch: numeric-string|null, 3: numeric-string|null}', $matches); + assertType('array{0: non-falsy-string, major: decimal-int-string, 1: decimal-int-string, minor: decimal-int-string, 2: decimal-int-string, patch: decimal-int-string|null, 3: decimal-int-string|null}', $matches); } } @@ -85,42 +85,42 @@ function (string $size): void { if (preg_match('/ab(\d){2,4}xx([0-9])?e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, numeric-string, numeric-string|null}', $matches); + assertType('array{non-falsy-string, decimal-int-string, decimal-int-string|null}', $matches); }; function (string $size): void { if (preg_match('/a(\dAB){2}b(\d){2,4}([1-5])([1-5a-z])e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string, numeric-string, numeric-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, decimal-int-string, decimal-int-string, non-empty-string}', $matches); }; function (string $size): void { if (preg_match('/ab(ab(\d)){2,4}xx([0-9][a-c])?e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string, numeric-string, non-falsy-string|null}', $matches); + assertType('array{non-falsy-string, non-falsy-string, decimal-int-string, non-falsy-string|null}', $matches); }; function (string $size): void { if (preg_match('/ab(\d+)e(\d?)/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType("array{non-falsy-string, numeric-string, ''|numeric-string}", $matches); + assertType("array{non-falsy-string, decimal-int-string, ''|decimal-int-string}", $matches); }; function (string $size): void { if (preg_match('/ab(?P\d+)e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); }; function (string $size): void { if (preg_match('/ab(\d\d)/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); }; function (string $size): void { @@ -162,12 +162,12 @@ function (string $size): void { if (preg_match('/ab(\d+\d?)e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string}', $matches); }; function (string $s): void { if (preg_match('/Price: ([2-5])/i', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string}', $matches); } }; @@ -191,26 +191,26 @@ function (string $s): void { function (string $s): void { preg_match('/%a(\d*)/', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("array{}|array{non-falsy-string, ''|numeric-string}", $matches); + assertType("array{}|array{non-falsy-string, ''|decimal-int-string}", $matches); }; function (string $s): void { preg_match('/%a(\d*)?/', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("array{}|array{non-falsy-string, ''|numeric-string|null}", $matches); + assertType("array{}|array{non-falsy-string, ''|decimal-int-string|null}", $matches); }; function (string $s): void { if (preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType("array{non-empty-string, numeric-string|null, non-empty-string|null}", $matches); + assertType("array{non-empty-string, decimal-int-string|null, non-empty-string|null}", $matches); } else { assertType("array{}", $matches); } - assertType("array{}|array{non-empty-string, numeric-string|null, non-empty-string|null}", $matches); + assertType("array{}|array{non-empty-string, decimal-int-string|null, non-empty-string|null}", $matches); }; function (string $s): void { if (preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_UNMATCHED_AS_NULL|PREG_OFFSET_CAPTURE) === 1) { - assertType("array{array{non-empty-string|null, int<-1, max>}, array{numeric-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}}", $matches); + assertType("array{array{non-empty-string|null, int<-1, max>}, array{decimal-int-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}}", $matches); } }; @@ -222,5 +222,5 @@ function (string $s): void { function (string $s): void { preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_UNMATCHED_AS_NULL); - assertType("array{}|array{non-empty-string, numeric-string|null, non-empty-string|null}", $matches); + assertType("array{}|array{non-empty-string, decimal-int-string|null, non-empty-string|null}", $matches); }; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14177.php b/tests/PHPStan/Analyser/nsrt/bug-14177.php index 26ef94e5577..29a9712032a 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14177.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14177.php @@ -26,7 +26,7 @@ public function placeholderToEditor(string $html): void function (array $matches): string { $id = (int) $matches[1]; - assertType('list{0: non-falsy-string, 1: numeric-string, 2?: string, 3?: string}', $matches); + assertType('list{0: non-falsy-string, 1: decimal-int-string, 2?: string, 3?: string}', $matches); $replacement = sprintf( '', @@ -34,7 +34,7 @@ function (array $matches): string { array_key_exists(3, $matches) ? sprintf(' class="%s"', $matches[3]) : '', ); - assertType('list{0: non-falsy-string, 1: numeric-string, 2?: string, 3?: string}', $matches); + assertType('list{0: non-falsy-string, 1: decimal-int-string, 2?: string, 3?: string}', $matches); return array_key_exists(2, $matches) && $matches[2] !== '' ? sprintf('%s', $matches[2], $replacement) @@ -51,7 +51,7 @@ public function placeholderToEditor2(string $html): void function (array $matches): string { $id = (int) $matches[0]; - assertType('list{0: non-falsy-string, 1?: \'\'|numeric-string, 2?: string, 3?: string}', $matches); + assertType('list{0: non-falsy-string, 1?: \'\'|decimal-int-string, 2?: string, 3?: string}', $matches); $replacement = sprintf( '', @@ -59,7 +59,7 @@ function (array $matches): string { array_key_exists(2, $matches) ? sprintf(' class="%s"', $matches[2]) : '', ); - assertType('list{0: non-falsy-string, 1?: \'\'|numeric-string, 2?: string, 3?: string}', $matches); + assertType('list{0: non-falsy-string, 1?: \'\'|decimal-int-string, 2?: string, 3?: string}', $matches); return array_key_exists(1, $matches) && $matches[1] !== '' ? sprintf('%s', $matches[1], $replacement) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14750.php b/tests/PHPStan/Analyser/nsrt/bug-14750.php new file mode 100644 index 00000000000..633ae9d2a67 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14750.php @@ -0,0 +1,66 @@ +, list}', $matches); + assertType('array{list, list}', $matches); }; function (string $size): void { preg_match_all('/ab(?P\d+)?/', $size, $matches); - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); }; function (string $size): void { preg_match_all('/ab(\d+)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_PATTERN_ORDER); - assertType('array{list, list}', $matches); + assertType('array{list, list}', $matches); }; function (string $size): void { preg_match_all('/ab(?P\d+)?/', $size, $matches, PREG_PATTERN_ORDER); - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); }; function (string $size): void { if (preg_match_all('/ab(?P\d+)?/', $size, $matches)) { - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); } else { assertType("array{}", $matches); } - assertType("array{}|array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{}|array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); }; function (string $size): void { if (preg_match_all('/ab(?P\d+)?/', $size, $matches) > 0) { - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); } else { - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); } - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); }; function (string $size): void { if (preg_match_all('/ab(?P\d+)?/', $size, $matches) != false) { - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); } else { assertType("array{}", $matches); } - assertType("array{}|array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{}|array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); }; function (string $size): void { if (preg_match_all('/ab(?P\d+)?/', $size, $matches) == true) { - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); } else { assertType("array{}", $matches); } - assertType("array{}|array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{}|array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); }; function (string $size): void { if (preg_match_all('/ab(?P\d+)?/', $size, $matches) === 1) { - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); } else { - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); } - assertType("array{0: list, num: list<''|numeric-string>, 1: list<''|numeric-string>}", $matches); + assertType("array{0: list, num: list<''|decimal-int-string>, 1: list<''|decimal-int-string>}", $matches); }; function (string $size): void { preg_match_all('/a(b)(\d+)?/', $size, $matches, PREG_SET_ORDER); - assertType("list", $matches); + assertType("list", $matches); }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches)) { - assertType("array{0: list, num: list, 1: list, suffix: list<''|'ab'>, 2: list<''|'ab'>}", $matches); + assertType("array{0: list, num: list, 1: list, suffix: list<''|'ab'>, 2: list<''|'ab'>}", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType("array{0: list, num: list, 1: list, suffix: list<'ab'|null>, 2: list<'ab'|null>}", $matches); + assertType("array{0: list, num: list, 1: list, suffix: list<'ab'|null>, 2: list<'ab'|null>}", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_SET_ORDER)) { - assertType("list", $matches); + assertType("list", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_PATTERN_ORDER)) { - assertType("array{0: list, num: list, 1: list, suffix: list<''|'ab'>, 2: list<''|'ab'>}", $matches); + assertType("array{0: list, num: list, 1: list, suffix: list<''|'ab'>, 2: list<''|'ab'>}", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_SET_ORDER)) { - assertType("list", $matches); + assertType("list", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_PATTERN_ORDER)) { - assertType("array{0: list, num: list, 1: list, suffix: list<'ab'|null>, 2: list<'ab'|null>}", $matches); + assertType("array{0: list, num: list, 1: list, suffix: list<'ab'|null>, 2: list<'ab'|null>}", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_SET_ORDER|PREG_OFFSET_CAPTURE)) { - assertType("list}, num: array{numeric-string, int<-1, max>}, 1: array{numeric-string, int<-1, max>}, suffix?: array{'ab', int<-1, max>}, 2?: array{'ab', int<-1, max>}}>", $matches); + assertType("list}, num: array{decimal-int-string, int<-1, max>}, 1: array{decimal-int-string, int<-1, max>}, suffix?: array{'ab', int<-1, max>}, 2?: array{'ab', int<-1, max>}}>", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE)) { - assertType("array{0: list}>, num: list}>, 1: list}>, suffix: list}>, 2: list}>}", $matches); + assertType("array{0: list}>, num: list}>, 1: list}>, suffix: list}>, 2: list}>}", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_SET_ORDER|PREG_OFFSET_CAPTURE)) { - assertType("list}, num: array{numeric-string|null, int<-1, max>}, 1: array{numeric-string|null, int<-1, max>}, suffix: array{'ab'|null, int<-1, max>}, 2: array{'ab'|null, int<-1, max>}}>", $matches); + assertType("list}, num: array{decimal-int-string|null, int<-1, max>}, 1: array{decimal-int-string|null, int<-1, max>}, suffix: array{'ab'|null, int<-1, max>}, 2: array{'ab'|null, int<-1, max>}}>", $matches); } }; function (string $size): void { if (preg_match_all('/ab(?P\d+)(?Pab)?/', $size, $matches, PREG_UNMATCHED_AS_NULL|PREG_PATTERN_ORDER|PREG_OFFSET_CAPTURE)) { - assertType("array{0: list}>, num: list}>, 1: list}>, suffix: list}>, 2: list}>}", $matches); + assertType("array{0: list}>, num: list}>, 1: list}>, suffix: list}>, 2: list}>}", $matches); } }; diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index 8dac3aa80ce..ea6655fc740 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -19,9 +19,9 @@ function doMatch(string $s): void { assertType("array{}|array{non-falsy-string, '£'|'€'}", $matches); if (preg_match('/Price: (£|€)(\d+)/i', $s, $matches)) { - assertType('array{non-falsy-string, non-empty-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-empty-string, decimal-int-string}', $matches); } - assertType('array{}|array{non-falsy-string, non-empty-string, numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, non-empty-string, decimal-int-string}', $matches); if (preg_match(' /Price: (£|€)\d+/ i u', $s, $matches)) { assertType('array{non-falsy-string, non-empty-string}', $matches); @@ -91,16 +91,16 @@ function doMatch(string $s): void { function doNonCapturingGroup(string $s): void { if (preg_match('/Price: (?:£|€)(\d+)/', $s, $matches)) { - assertType('array{non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string}', $matches); } - assertType('array{}|array{non-falsy-string, numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, decimal-int-string}', $matches); } function doNamedSubpattern(string $s): void { if (preg_match('/\w-(?P\d+)-(\w)/', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string, 2: non-empty-string}', $matches); + assertType('array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string, 2: non-empty-string}', $matches); } - assertType('array{}|array{0: non-falsy-string, num: numeric-string, 1: numeric-string, 2: non-empty-string}', $matches); + assertType('array{}|array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string, 2: non-empty-string}', $matches); if (preg_match('/^(?\S+::\S+)/', $s, $matches)) { assertType('array{0: non-falsy-string, name: non-falsy-string, 1: non-falsy-string}', $matches); @@ -129,16 +129,16 @@ function doUnknownFlags(string $s, int $flags): void { function doMultipleAlternativeCaptureGroupsWithSameNameWithModifier(string $s): void { if (preg_match('/(?J)(?[a-z]+)|(?[0-9]+)/', $s, $matches)) { - assertType("array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); + assertType("array{0: non-empty-string, Foo: decimal-int-string, 1: '', 2: decimal-int-string}|array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}", $matches); } - assertType("array{}|array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); + assertType("array{}|array{0: non-empty-string, Foo: decimal-int-string, 1: '', 2: decimal-int-string}|array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}", $matches); } function doMultipleConsecutiveCaptureGroupsWithSameNameWithModifier(string $s): void { if (preg_match('/(?J)(?[a-z]+)|(?[0-9]+)/', $s, $matches)) { - assertType("array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); + assertType("array{0: non-empty-string, Foo: decimal-int-string, 1: '', 2: decimal-int-string}|array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}", $matches); } - assertType("array{}|array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}|array{0: non-empty-string, Foo: numeric-string, 1: '', 2: numeric-string}", $matches); + assertType("array{}|array{0: non-empty-string, Foo: decimal-int-string, 1: '', 2: decimal-int-string}|array{0: non-empty-string, Foo: non-empty-string, 1: non-empty-string}", $matches); } // https://github.com/hoaproject/Regex/issues/31 @@ -149,9 +149,9 @@ function hoaBug31(string $s): void { assertType('array{}|array{non-empty-string, non-empty-string}', $matches); if (preg_match('/\w-(\d+)-(\w)/', $s, $matches)) { - assertType('array{non-falsy-string, numeric-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string, non-empty-string}', $matches); } - assertType('array{}|array{non-falsy-string, numeric-string, non-empty-string}', $matches); + assertType('array{}|array{non-falsy-string, decimal-int-string, non-empty-string}', $matches); } // https://github.com/phpstan/phpstan/issues/10855#issuecomment-2044323638 @@ -225,9 +225,9 @@ function testUnionPattern(string $s): void $pattern = '/Price: (\d+)(\d+)(\d+)/'; } if (preg_match($pattern, $s, $matches)) { - assertType('array{non-falsy-string, numeric-string, numeric-string, numeric-string}|array{non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string, decimal-int-string, decimal-int-string}|array{non-falsy-string, decimal-int-string}', $matches); } - assertType('array{}|array{non-falsy-string, numeric-string, numeric-string, numeric-string}|array{non-falsy-string, numeric-string}', $matches); + assertType('array{}|array{non-falsy-string, decimal-int-string, decimal-int-string, decimal-int-string}|array{non-falsy-string, decimal-int-string}', $matches); } function doFoo(string $row): void @@ -249,7 +249,7 @@ function doFoo2(string $row): void return; } - assertType("array{0: non-falsy-string, 1: string, branchCode: ''|numeric-string, 2: ''|numeric-string, accountNumber: numeric-string, 3: numeric-string, bankCode: non-falsy-string&numeric-string, 4: non-falsy-string&numeric-string}", $matches); + assertType("array{0: non-falsy-string, 1: string, branchCode: ''|decimal-int-string, 2: ''|decimal-int-string, accountNumber: decimal-int-string, 3: decimal-int-string, bankCode: decimal-int-string&non-falsy-string, 4: decimal-int-string&non-falsy-string}", $matches); } function doFoo3(string $row): void @@ -258,42 +258,42 @@ function doFoo3(string $row): void return; } - assertType('array{non-falsy-string, non-falsy-string, non-falsy-string, numeric-string, numeric-string, numeric-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, non-falsy-string, decimal-int-string, decimal-int-string, decimal-int-string, decimal-int-string}', $matches); } function (string $size): void { if (preg_match('~^a\.b(c(\d+)(\d+)(\s+))?d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string, numeric-string, numeric-string, non-empty-string}|array{non-falsy-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, decimal-int-string, decimal-int-string, non-empty-string}|array{non-falsy-string}', $matches); }; function (string $size): void { if (preg_match('~^a\.b(c(\d+))?d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string, numeric-string}|array{non-falsy-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, decimal-int-string}|array{non-falsy-string}', $matches); }; function (string $size): void { if (preg_match('~^a\.b(c(\d+)?)d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{0: non-falsy-string, 1: non-falsy-string, 2?: numeric-string}', $matches); + assertType('array{0: non-falsy-string, 1: non-falsy-string, 2?: decimal-int-string}', $matches); }; function (string $size): void { if (preg_match('~^a\.b(c(\d+)?)?d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('list{0: non-falsy-string, 1?: non-falsy-string, 2?: numeric-string}', $matches); + assertType('list{0: non-falsy-string, 1?: non-falsy-string, 2?: decimal-int-string}', $matches); }; function (string $size): void { if (preg_match('~^a\.b(c(\d+))d~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType('array{non-falsy-string, non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, decimal-int-string}', $matches); }; function (string $size): void { @@ -307,14 +307,14 @@ function (string $size): void { if (preg_match('~^(?:(\\d+)x(\\d+)|(\\d+)|x(\\d+))$~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType("array{non-empty-string, '', '', '', numeric-string}|array{non-empty-string, '', '', numeric-string}|array{non-empty-string, numeric-string, numeric-string}", $matches); + assertType("array{non-empty-string, '', '', '', decimal-int-string}|array{non-empty-string, '', '', decimal-int-string}|array{non-empty-string, decimal-int-string, decimal-int-string}", $matches); }; function (string $size): void { if (preg_match('~^(?:(\\d+)x(\\d+)|(\\d+)|x(\\d+))?$~', $size, $matches) !== 1) { throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); } - assertType("array{string, '', '', '', numeric-string}|array{string, '', '', numeric-string}|array{string, numeric-string, numeric-string}|array{string}", $matches); + assertType("array{string, '', '', '', decimal-int-string}|array{string, '', '', decimal-int-string}|array{string, decimal-int-string, decimal-int-string}|array{string}", $matches); }; function (string $size): void { @@ -402,11 +402,11 @@ function (string $s): void { function (string $s): void { if (preg_match('/' . preg_quote($s, '/') . '(\d)/', $s, $matches)) { - assertType('array{non-empty-string, numeric-string}', $matches); + assertType('array{non-empty-string, decimal-int-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{non-empty-string, numeric-string}', $matches); + assertType('array{}|array{non-empty-string, decimal-int-string}', $matches); }; function (string $s): void { @@ -435,13 +435,13 @@ function (string $s): void { function (string $s): void { if (preg_match('~^((\\d{1,6})-)$~', $s, $matches) === 1) { - assertType("array{non-falsy-string, non-falsy-string, numeric-string}", $matches); + assertType("array{non-falsy-string, non-falsy-string, decimal-int-string}", $matches); } }; function (string $s): void { if (preg_match('~^((\\d{1,6}).)$~', $s, $matches) === 1) { - assertType("array{non-falsy-string, non-falsy-string, numeric-string}", $matches); + assertType("array{non-falsy-string, non-falsy-string, decimal-int-string}", $matches); } }; @@ -468,22 +468,22 @@ function bug11323(string $s): void { assertType("array{non-falsy-string, non-falsy-string, 'a-z'}", $matches); } if (preg_match('{(\d+)(?i)insensitive((?xs-i)case SENSITIVE here.+and dot matches new lines)}', $s, $matches)) { - assertType('array{non-falsy-string, numeric-string, non-falsy-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string, non-falsy-string}', $matches); } if (preg_match('{(\d+)(?i)insensitive((?x-i)case SENSITIVE here(?i:insensitive non-capturing group))}', $s, $matches)) { - assertType('array{non-falsy-string, numeric-string, non-falsy-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string, non-falsy-string}', $matches); } if (preg_match('{([]] [^]])}', $s, $matches)) { assertType('array{non-falsy-string, non-falsy-string}', $matches); } if (preg_match('{([[:digit:]])}', $s, $matches)) { - assertType('array{non-empty-string, numeric-string}', $matches); + assertType('array{non-empty-string, decimal-int-string}', $matches); } if (preg_match('{([\d])(\d)}', $s, $matches)) { - assertType('array{non-falsy-string, numeric-string, numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string, decimal-int-string}', $matches); } if (preg_match('{([0-9])}', $s, $matches)) { - assertType('array{non-empty-string, numeric-string}', $matches); + assertType('array{non-empty-string, decimal-int-string}', $matches); } if (preg_match('{(\p{L})(\p{P})(\p{Po})}', $s, $matches)) { assertType('array{non-falsy-string, non-empty-string, non-empty-string, non-empty-string}', $matches); @@ -498,19 +498,19 @@ function bug11323(string $s): void { assertType('array{non-falsy-string, non-falsy-string}', $matches); } if (preg_match('{(\d\d)}', $s, $matches)) { - assertType('array{non-falsy-string, non-falsy-string&numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string}', $matches); } if (preg_match('{(.(\d))}', $s, $matches)) { - assertType('array{non-falsy-string, non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, decimal-int-string}', $matches); } if (preg_match('{((\d).)}', $s, $matches)) { - assertType('array{non-falsy-string, non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, decimal-int-string}', $matches); } if (preg_match('{(\d([1-4])\d)}', $s, $matches)) { - assertType('array{non-falsy-string, non-falsy-string&numeric-string, numeric-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string&non-falsy-string, decimal-int-string}', $matches); } if (preg_match('{(x?([1-4])\d)}', $s, $matches)) { - assertType('array{non-falsy-string, non-falsy-string, numeric-string}', $matches); + assertType('array{non-falsy-string, non-falsy-string, decimal-int-string}', $matches); } if (preg_match('{([^1-4])}', $s, $matches)) { assertType('array{non-empty-string, non-empty-string}', $matches); @@ -525,7 +525,7 @@ function bug11323(string $s): void { function (string $s): void { preg_match('/%a(\d*)/', $s, $matches); - assertType("array{}|array{non-falsy-string, ''|numeric-string}", $matches); + assertType("array{}|array{non-falsy-string, ''|decimal-int-string}", $matches); }; class Bug11376 @@ -552,7 +552,7 @@ function (string $s): void { } if (preg_match($p, $s, $matches)) { - assertType("array{non-falsy-string, '£', 'abc'}|array{non-falsy-string, numeric-string, 'b'}", $matches); + assertType("array{non-falsy-string, '£', 'abc'}|array{non-falsy-string, decimal-int-string, 'b'}", $matches); } }; @@ -564,7 +564,7 @@ function (string $s): void { } if (preg_match($p, $s, $matches)) { - assertType("list{0: non-falsy-string, 1: 'x'|'£'|numeric-string, 2?: ''|numeric-string, 3?: 'x'}", $matches); + assertType("list{0: non-falsy-string, 1: 'x'|'£'|decimal-int-string, 2?: ''|decimal-int-string, 3?: 'x'}", $matches); } }; @@ -576,7 +576,7 @@ function (string $s): void { function (string $s): void { if (preg_match('/Price: ([0-9])/i', $s, $matches)) { - assertType("array{non-falsy-string, numeric-string}", $matches); + assertType("array{non-falsy-string, decimal-int-string}", $matches); } }; @@ -630,13 +630,13 @@ function (string $s): void { function (string $s): void { if (preg_match('/Price: (a|\d)/', $s, $matches)) { - assertType("array{non-falsy-string, 'a'|numeric-string}", $matches); + assertType("array{non-falsy-string, 'a'|decimal-int-string}", $matches); } }; function (string $s): void { if (preg_match('/Price: (?a|\d)/', $s, $matches)) { - assertType("array{0: non-falsy-string, named: 'a'|numeric-string, 1: 'a'|numeric-string}", $matches); + assertType("array{0: non-falsy-string, named: 'a'|decimal-int-string, 1: 'a'|decimal-int-string}", $matches); } }; @@ -654,7 +654,7 @@ function (string $s): void { function (string $s): void { if (preg_match('/( \d+ )/x', $s, $matches)) { - assertType('array{non-empty-string, numeric-string}', $matches); + assertType('array{non-empty-string, decimal-int-string}', $matches); } }; @@ -706,13 +706,13 @@ static public function sayHello(string $source): void // 2 => '1', //) - assertType("array{}|array{0: string, dateFrom?: ''|numeric-string, 1?: ''|numeric-string, dateTo?: numeric-string, 2?: numeric-string}", $matches); + assertType("array{}|array{0: string, dateFrom?: ''|decimal-int-string, 1?: ''|decimal-int-string, dateTo?: decimal-int-string, 2?: decimal-int-string}", $matches); } } function (string $s): void { if (preg_match('~a|(\d)|(\s)~', $s, $matches) === 1) { - assertType("array{0: non-empty-string, 1?: numeric-string}|array{non-empty-string, '', non-empty-string}", $matches); + assertType("array{0: non-empty-string, 1?: decimal-int-string}|array{non-empty-string, '', non-empty-string}", $matches); } }; @@ -724,20 +724,20 @@ function (string $s): void { function (string $s): void { if (preg_match('~a|(\d)|(\s)~', $s, $matches, PREG_OFFSET_CAPTURE) === 1) { - assertType("array{0: array{non-empty-string, int<-1, max>}, 1?: array{numeric-string, int<-1, max>}}|array{array{non-empty-string, int<-1, max>}, array{'', int<-1, max>}, array{non-empty-string, int<-1, max>}}", $matches); + assertType("array{0: array{non-empty-string, int<-1, max>}, 1?: array{decimal-int-string, int<-1, max>}}|array{array{non-empty-string, int<-1, max>}, array{'', int<-1, max>}, array{non-empty-string, int<-1, max>}}", $matches); } }; function (string $s): void { preg_match('~a|(\d)|(\s)~', $s, $matches); - assertType("array{}|array{0: non-empty-string, 1?: numeric-string}|array{non-empty-string, '', non-empty-string}", $matches); + assertType("array{}|array{0: non-empty-string, 1?: decimal-int-string}|array{non-empty-string, '', non-empty-string}", $matches); }; function bug11490 (string $expression): void { $matches = []; if (preg_match('/([-+])?([\d]+)%/', $expression, $matches) === 1) { - assertType("array{non-falsy-string, ''|'+'|'-', numeric-string}", $matches); + assertType("array{non-falsy-string, ''|'+'|'-', decimal-int-string}", $matches); } } @@ -745,7 +745,7 @@ function bug11490b (string $expression): void { $matches = []; if (preg_match('/([\\[+])?([\d]+)%/', $expression, $matches) === 1) { - assertType("array{non-falsy-string, ''|'+'|'[', numeric-string}", $matches); + assertType("array{non-falsy-string, ''|'+'|'[', decimal-int-string}", $matches); } } @@ -792,7 +792,7 @@ function testUnescapeBackslash (string $string): void { if (preg_match(<<<'EOD' ~(\d)~ EOD, $string, $matches)) { - assertType("array{non-empty-string, numeric-string}", $matches); + assertType("array{non-empty-string, decimal-int-string}", $matches); } if (preg_match(<<<'EOD' @@ -1013,7 +1013,7 @@ function bug12749f(string $str): void function bug12397(string $string): void { $m = preg_match('#\b([A-Z]{2,})-(\d+)#', $string, $match); - assertType("array{}|array{non-falsy-string, non-falsy-string, numeric-string}", $match); + assertType("array{}|array{non-falsy-string, non-falsy-string, decimal-int-string}", $match); } function bug12792(string $string): void { @@ -1085,6 +1085,6 @@ function testExtendedSyntaxEscapedHash(string $string): void { (\d+) # this is a comment ([\#ab]+) # hash in character class (escaped) /x', $string, $matches)) { - assertType('array{non-falsy-string, numeric-string, non-empty-string}', $matches); + assertType('array{non-falsy-string, decimal-int-string, non-empty-string}', $matches); } } diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php index 4620565210c..f5f2990bc28 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php @@ -15,7 +15,7 @@ function doOffsetCaptureWithUnmatchedNull(string $s): void { function doNonAutoCapturingModifier(string $s): void { if (preg_match('/(?n)(\d+)/', $s, $matches)) { // should be assertType('array{string}', $matches); - assertType('array{non-empty-string, numeric-string}', $matches); + assertType('array{non-empty-string, decimal-int-string}', $matches); } - assertType('array{}|array{non-empty-string, numeric-string}', $matches); + assertType('array{}|array{non-empty-string, decimal-int-string}', $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php index 1b5dc597b7f..4be11d9712b 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php @@ -13,34 +13,34 @@ function doNonAutoCapturingFlag(string $s): void { assertType('array{}|array{non-empty-string}', $matches); if (preg_match('/(\d+)(?P\d+)/n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); } - assertType('array{}|array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{}|array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); if (preg_match('/(\w)-(?P\d+)-(\w)/n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); } - assertType('array{}|array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{}|array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); } // delimiter variants, see https://www.php.net/manual/en/regexp.reference.delimiters.php function (string $s): void { if (preg_match('{(\d+)(?P\d+)}n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); } }; function (string $s): void { if (preg_match('<(\d+)(?P\d+)>n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); } }; function (string $s): void { if (preg_match('((\d+)(?P\d+))n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); } }; function (string $s): void { if (preg_match('[(\d+)(?P\d+)]n', $s, $matches)) { - assertType('array{0: non-falsy-string, num: numeric-string, 1: numeric-string}', $matches); + assertType('array{0: non-falsy-string, num: decimal-int-string, 1: decimal-int-string}', $matches); } }; From acd30a180cb93f92ef6bc1f4f03b493f8375418b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 2 Jun 2026 17:34:39 +0000 Subject: [PATCH 2/7] Remove default value of walkGroupAst() $inNegativeClass parameter Co-Authored-By: Claude Opus 4.8 --- src/Type/Regex/RegexGroupParser.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index dc7466f2c94..af7986cbcde 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -124,6 +124,7 @@ public function parseGroups(string $regex): ?RegexAstWalkResult false, $modifiers, RegexGroupWalkResult::createEmpty(), + false, ); if (!$subjectAsGroupResult->mightContainEmptyStringLiteral() && !$this->containsEscapeK($ast)) { @@ -411,6 +412,7 @@ private function createGroupType(TreeNode $group, bool $maybeConstant, string $p false, $patternModifiers, RegexGroupWalkResult::createEmpty(), + false, ); if ($maybeConstant && $walkResult->getOnlyLiterals() !== null && $walkResult->getOnlyLiterals() !== []) { @@ -481,7 +483,7 @@ private function walkGroupAst( bool $inClass, string $patternModifiers, RegexGroupWalkResult $walkResult, - bool $inNegativeClass = false, + bool $inNegativeClass, ): RegexGroupWalkResult { $children = $ast->getChildren(); From 4fdc652b10f5c20155660a44bd869430b7cb4fa2 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 2 Jun 2026 17:40:58 +0000 Subject: [PATCH 3/7] Drop non-decimal-int-string inference from regex matching Removes the negatedClassExcludesAllDigits() helper (and the now-unused $inNegativeClass parameter / non-decimal walk-result plumbing) per review. Regex capturing groups that can only match non-digits are no longer narrowed to non-decimal-int-string; decimal-int-string inference for digit groups is unchanged. Co-Authored-By: Claude Opus 4.8 --- src/Type/Regex/RegexGroupParser.php | 105 ++++------------------ src/Type/Regex/RegexGroupWalkResult.php | 27 ------ tests/PHPStan/Analyser/nsrt/bug-14750.php | 15 +--- 3 files changed, 19 insertions(+), 128 deletions(-) diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index af7986cbcde..91ebde420a0 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -24,11 +24,9 @@ use PHPStan\Type\UnionType; use function array_key_exists; use function array_values; -use function chr; use function count; use function in_array; use function is_int; -use function ord; use function preg_replace; use function rtrim; use function sscanf; @@ -124,7 +122,6 @@ public function parseGroups(string $regex): ?RegexAstWalkResult false, $modifiers, RegexGroupWalkResult::createEmpty(), - false, ); if (!$subjectAsGroupResult->mightContainEmptyStringLiteral() && !$this->containsEscapeK($ast)) { @@ -412,7 +409,6 @@ private function createGroupType(TreeNode $group, bool $maybeConstant, string $p false, $patternModifiers, RegexGroupWalkResult::createEmpty(), - false, ); if ($maybeConstant && $walkResult->getOnlyLiterals() !== null && $walkResult->getOnlyLiterals() !== []) { @@ -438,15 +434,6 @@ private function createGroupType(TreeNode $group, bool $maybeConstant, string $p return new UnionType([new ConstantStringType(''), $result]); } return $result; - } elseif ($walkResult->isNonDecimalInteger()->yes()) { - $accessories = [new StringType(), new AccessoryDecimalIntegerStringType(true)]; - if ($walkResult->isNonFalsy()->yes()) { - $accessories[] = new AccessoryNonFalsyStringType(); - } elseif ($walkResult->isNonEmpty()->yes()) { - $accessories[] = new AccessoryNonEmptyStringType(); - } - - return new IntersectionType($accessories); } elseif ($walkResult->isNonFalsy()->yes()) { return new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]); } elseif ($walkResult->isNonEmpty()->yes()) { @@ -483,7 +470,6 @@ private function walkGroupAst( bool $inClass, string $patternModifiers, RegexGroupWalkResult $walkResult, - bool $inNegativeClass, ): RegexGroupWalkResult { $children = $ast->getChildren(); @@ -555,27 +541,19 @@ private function walkGroupAst( $walkResult = $walkResult->onlyLiterals($onlyLiterals); if ($literalValue !== null) { - if (!$inNegativeClass) { - if (Strings::match($literalValue, '/^\d+$/') !== null) { - if ($walkResult->isDecimalInteger()->maybe()) { - $walkResult = $walkResult->decimalInteger(TrinaryLogic::createYes()); - } - } elseif ( - $literalValue === '-' - && $walkResult->isDecimalInteger()->maybe() - && !$walkResult->hasSeenDecimalIntegerSign() - ) { - // a single leading minus sign keeps the string a decimal integer (e.g. "-1") - $walkResult = $walkResult->seenDecimalIntegerSign(true); - } else { - $walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo()); - } - - // a literal token outside a negative class might be (part of) a decimal integer, - // so we can no longer guarantee the absence of one - if ($literalValue !== '') { - $walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createNo()); + if (Strings::match($literalValue, '/^\d+$/') !== null) { + if ($walkResult->isDecimalInteger()->maybe()) { + $walkResult = $walkResult->decimalInteger(TrinaryLogic::createYes()); } + } elseif ( + $literalValue === '-' + && $walkResult->isDecimalInteger()->maybe() + && !$walkResult->hasSeenDecimalIntegerSign() + ) { + // a single leading minus sign keeps the string a decimal integer (e.g. "-1") + $walkResult = $walkResult->seenDecimalIntegerSign(true); + } else { + $walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo()); } if (!$walkResult->isInOptionalQuantification() && $literalValue !== '') { @@ -595,7 +573,6 @@ private function walkGroupAst( $nonEmpty = TrinaryLogic::createYes(); $nonFalsy = TrinaryLogic::createYes(); $decimalInteger = TrinaryLogic::createYes(); - $nonDecimalInteger = TrinaryLogic::createYes(); foreach ($children as $child) { $childResult = $this->walkGroupAst( $child, @@ -605,15 +582,12 @@ private function walkGroupAst( ->nonEmpty(TrinaryLogic::createMaybe()) ->nonFalsy(TrinaryLogic::createMaybe()) ->decimalInteger(TrinaryLogic::createMaybe()) - ->nonDecimalInteger(TrinaryLogic::createMaybe()) ->seenDecimalIntegerSign(false), - $inNegativeClass, ); $nonEmpty = $nonEmpty->and($childResult->isNonEmpty()); $nonFalsy = $nonFalsy->and($childResult->isNonFalsy()); $decimalInteger = $decimalInteger->and($childResult->isDecimalInteger()); - $nonDecimalInteger = $nonDecimalInteger->and($childResult->isNonDecimalInteger()); if ($newLiterals === null) { continue; @@ -632,23 +606,14 @@ private function walkGroupAst( ->onlyLiterals($newLiterals) ->nonEmpty($walkResult->isNonEmpty()->or($nonEmpty)) ->nonFalsy($walkResult->isNonFalsy()->or($nonFalsy)) - ->decimalInteger($walkResult->isDecimalInteger()->and($decimalInteger)) - ->nonDecimalInteger($walkResult->isNonDecimalInteger()->and($nonDecimalInteger)); + ->decimalInteger($walkResult->isDecimalInteger()->and($decimalInteger)); } - // a negative class never matches a decimal integer on its own; when it excludes every - // digit it can only match non-digit characters, so the result is a non-decimal-int-string + // [^0-9] should not parse as decimal-int-string, and [^list-everything-but-numbers] is technically + // doable but really silly compared to just \d so we can safely assume the string is not a decimal + // integer for negative classes if ($ast->getId() === '#negativeclass') { $walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo()); - if ($this->negatedClassExcludesAllDigits($ast)) { - if ($walkResult->isNonDecimalInteger()->maybe()) { - $walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createYes()); - } - } else { - $walkResult = $walkResult->nonDecimalInteger(TrinaryLogic::createNo()); - } - - $inNegativeClass = true; } foreach ($children as $child) { @@ -657,50 +622,12 @@ private function walkGroupAst( $inClass, $patternModifiers, $walkResult, - $inNegativeClass, ); } return $walkResult; } - private function negatedClassExcludesAllDigits(TreeNode $negativeClass): bool - { - $excludedDigits = []; - foreach ($negativeClass->getChildren() as $child) { - if ($child->getId() === '#range') { - $from = $child->getChild(0)->getValueValue(); - $to = $child->getChild(1)->getValueValue(); - if (strlen($from) === 1 && strlen($to) === 1) { - for ($ord = ord($from); $ord <= ord($to); $ord++) { - $char = chr($ord); - if (Strings::match($char, '/^\d$/') === null) { - continue; - } - $excludedDigits[$char] = true; - } - } - } elseif ($child->getId() === 'token') { - $value = $child->getValueValue(); - if ($child->getValueToken() === 'character_type' && $value === '\d') { - for ($digit = 0; $digit <= 9; $digit++) { - $excludedDigits[(string) $digit] = true; - } - } elseif (Strings::match($value, '/^\d$/') !== null) { - $excludedDigits[$value] = true; - } - } - } - - for ($digit = 0; $digit <= 9; $digit++) { - if (!array_key_exists((string) $digit, $excludedDigits)) { - return false; - } - } - - return true; - } - private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy): bool { if ($node->getId() === '#quantification') { diff --git a/src/Type/Regex/RegexGroupWalkResult.php b/src/Type/Regex/RegexGroupWalkResult.php index ecbf6fc53ab..db6c3e1cd67 100644 --- a/src/Type/Regex/RegexGroupWalkResult.php +++ b/src/Type/Regex/RegexGroupWalkResult.php @@ -17,7 +17,6 @@ public function __construct( private TrinaryLogic $isNonEmpty, private TrinaryLogic $isNonFalsy, private TrinaryLogic $isDecimalInteger, - private TrinaryLogic $isNonDecimalInteger, private bool $seenDecimalIntegerSign, ) { @@ -31,7 +30,6 @@ public static function createEmpty(): self TrinaryLogic::createMaybe(), TrinaryLogic::createMaybe(), TrinaryLogic::createMaybe(), - TrinaryLogic::createMaybe(), false, ); } @@ -44,7 +42,6 @@ public function inOptionalQuantification(bool $inOptionalQuantification): self $this->isNonEmpty, $this->isNonFalsy, $this->isDecimalInteger, - $this->isNonDecimalInteger, $this->seenDecimalIntegerSign, ); } @@ -60,7 +57,6 @@ public function onlyLiterals(?array $onlyLiterals): self $this->isNonEmpty, $this->isNonFalsy, $this->isDecimalInteger, - $this->isNonDecimalInteger, $this->seenDecimalIntegerSign, ); } @@ -73,7 +69,6 @@ public function nonEmpty(TrinaryLogic $nonEmpty): self $nonEmpty, $this->isNonFalsy, $this->isDecimalInteger, - $this->isNonDecimalInteger, $this->seenDecimalIntegerSign, ); } @@ -86,7 +81,6 @@ public function nonFalsy(TrinaryLogic $nonFalsy): self $this->isNonEmpty, $nonFalsy, $this->isDecimalInteger, - $this->isNonDecimalInteger, $this->seenDecimalIntegerSign, ); } @@ -100,21 +94,6 @@ public function decimalInteger(TrinaryLogic $decimalInteger): self $this->isNonEmpty, $this->isNonFalsy, $decimalInteger, - $this->isNonDecimalInteger, - $this->seenDecimalIntegerSign, - ); - } - - /** A non-decimal integer string is guaranteed to contain no decimal integer (e.g. it has no digits at all). */ - public function nonDecimalInteger(TrinaryLogic $nonDecimalInteger): self - { - return new self( - $this->inOptionalQuantification, - $this->onlyLiterals, - $this->isNonEmpty, - $this->isNonFalsy, - $this->isDecimalInteger, - $nonDecimalInteger, $this->seenDecimalIntegerSign, ); } @@ -127,7 +106,6 @@ public function seenDecimalIntegerSign(bool $seenDecimalIntegerSign): self $this->isNonEmpty, $this->isNonFalsy, $this->isDecimalInteger, - $this->isNonDecimalInteger, $seenDecimalIntegerSign, ); } @@ -174,11 +152,6 @@ public function isDecimalInteger(): TrinaryLogic return $this->isDecimalInteger; } - public function isNonDecimalInteger(): TrinaryLogic - { - return $this->isNonDecimalInteger; - } - public function hasSeenDecimalIntegerSign(): bool { return $this->seenDecimalIntegerSign; diff --git a/tests/PHPStan/Analyser/nsrt/bug-14750.php b/tests/PHPStan/Analyser/nsrt/bug-14750.php index 633ae9d2a67..43654b91456 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14750.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14750.php @@ -22,8 +22,9 @@ function pregMatchDecimalIntStringTypeMatches(string $x): void assertType('decimal-int-string', $matches[1]); } + // a negated digit class does not match a decimal integer if (preg_match('/^([^0-9])$/', $x, $matches)) { - assertType('non-decimal-int-string&non-empty-string', $matches[1]); + assertType('non-empty-string', $matches[1]); } } @@ -49,18 +50,8 @@ function edgeCases(string $x): void assertType('non-empty-string', $matches[1]); } - // quantified negated digit class only matches non-digits + // a negated digit class never yields a decimal integer if (preg_match('/^([^0-9]+)$/', $x, $matches)) { - assertType('non-decimal-int-string&non-empty-string', $matches[1]); - } - - // negated class that does not exclude every digit can still match a decimal integer - if (preg_match('/^([^1-4])$/', $x, $matches)) { assertType('non-empty-string', $matches[1]); } - - // alternation of a digit and a negated digit class - if (preg_match('/^(\d|[^0-9])$/', $x, $matches)) { - assertType('decimal-int-string|(non-decimal-int-string&non-empty-string)', $matches[1]); - } } From 47c31ba75ab2ea37cbf5391cee5ce5a4106c9cf7 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 3 Jun 2026 11:29:01 +0200 Subject: [PATCH 4/7] Don't invalidate decimal-integer on empty-string literals --- src/Type/Regex/RegexGroupParser.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index 91ebde420a0..7b63317b6c1 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -552,7 +552,7 @@ private function walkGroupAst( ) { // a single leading minus sign keeps the string a decimal integer (e.g. "-1") $walkResult = $walkResult->seenDecimalIntegerSign(true); - } else { + } elseif ($literalValue !== '') { $walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo()); } From 3ad4c7e7503e4044b74ad3289b9dc7f6c5cd7fb4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 3 Jun 2026 13:17:43 +0200 Subject: [PATCH 5/7] handle numeric-string, in case we know the regex is delimited by ^ and $ --- src/Type/Regex/RegexExpressionHelper.php | 9 +++++++ src/Type/Regex/RegexGroupParser.php | 24 +++++++++++++++---- tests/PHPStan/Analyser/nsrt/bug-14750.php | 12 +++++++--- .../Analyser/nsrt/preg_match_shapes.php | 4 ++-- .../Type/Regex/RegexExpressionHelperTest.php | 20 ++++++++++++++++ 5 files changed, 59 insertions(+), 10 deletions(-) diff --git a/src/Type/Regex/RegexExpressionHelper.php b/src/Type/Regex/RegexExpressionHelper.php index dada8e2c5c9..ef14c0f4d7e 100644 --- a/src/Type/Regex/RegexExpressionHelper.php +++ b/src/Type/Regex/RegexExpressionHelper.php @@ -13,6 +13,8 @@ use PHPStan\Type\TypeCombinator; use function array_key_exists; use function ltrim; +use function str_ends_with; +use function str_starts_with; use function strrpos; use function substr; @@ -89,6 +91,13 @@ public function getPatternModifiers(string $pattern): ?string return substr($pattern, $endDelimiterPos + 1); } + public function isAnchoredPattern(string $pattern): bool + { + $cleanedPattern = $this->removeDelimitersAndModifiers($pattern); + + return str_starts_with($cleanedPattern, '^') && str_ends_with($cleanedPattern, '$'); + } + public function removeDelimitersAndModifiers(string $pattern): string { $pattern = ltrim($pattern); diff --git a/src/Type/Regex/RegexGroupParser.php b/src/Type/Regex/RegexGroupParser.php index 7b63317b6c1..5d8e2d7bfd8 100644 --- a/src/Type/Regex/RegexGroupParser.php +++ b/src/Type/Regex/RegexGroupParser.php @@ -125,8 +125,14 @@ public function parseGroups(string $regex): ?RegexAstWalkResult ); if (!$subjectAsGroupResult->mightContainEmptyStringLiteral() && !$this->containsEscapeK($ast)) { - // we could handle numeric-string, in case we know the regex is delimited by ^ and $ - if ($subjectAsGroupResult->isNonFalsy()->yes()) { + if ( + $subjectAsGroupResult->isDecimalInteger()->yes() + && $this->regexExpressionHelper->isAnchoredPattern($regex) + ) { + $astWalkResult = $astWalkResult->withSubjectBaseType( + new IntersectionType([new StringType(), new AccessoryDecimalIntegerStringType()]), + ); + } elseif ($subjectAsGroupResult->isNonFalsy()->yes()) { $astWalkResult = $astWalkResult->withSubjectBaseType( new IntersectionType([new StringType(), new AccessoryNonFalsyStringType()]), ); @@ -482,12 +488,17 @@ private function walkGroupAst( $meaningfulTokens = 0; foreach ($children as $child) { $nonFalsy = false; - if ($this->isMaybeEmptyNode($child, $patternModifiers, $nonFalsy)) { + $isNonDecimal = false; + if ($this->isMaybeEmptyNode($child, $patternModifiers, $nonFalsy, $isNonDecimal)) { continue; } $meaningfulTokens++; + if ($isNonDecimal) { + $walkResult = $walkResult->decimalInteger(TrinaryLogic::createNo()); + } + if (!$nonFalsy) { continue; } @@ -628,7 +639,7 @@ private function walkGroupAst( return $walkResult; } - private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy): bool + private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool &$isNonFalsy, bool &$isNonDecimal): bool { if ($node->getId() === '#quantification') { [$min] = $this->getQuantificationRange($node); @@ -647,11 +658,14 @@ private function isMaybeEmptyNode(TreeNode $node, string $patternModifiers, bool if ($literal !== '' && $literal !== '0') { $isNonFalsy = true; } + if (Strings::match($literal, '/^\d+$/') === null) { + $isNonDecimal = true; + } return $literal === ''; } foreach ($node->getChildren() as $child) { - if (!$this->isMaybeEmptyNode($child, $patternModifiers, $isNonFalsy)) { + if (!$this->isMaybeEmptyNode($child, $patternModifiers, $isNonFalsy, $isNonDecimal)) { return false; } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14750.php b/tests/PHPStan/Analyser/nsrt/bug-14750.php index 43654b91456..fc4dc15f027 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14750.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14750.php @@ -6,8 +6,14 @@ function pregMatchDecimalIntStringTypeMatches(string $x): void { - if (preg_match('/^(-?[0-9]+)$/', $x, $matches)) { - assertType('decimal-int-string', $matches[1]); + if (preg_match('/^(-?[0-9]+)$/', $x, $matches)) { // all chars in string are "-" or numbers + assertType('decimal-int-string', $x); + assertType('array{decimal-int-string, decimal-int-string}', $matches); + } + + if (preg_match('/(-?[0-9]+)/', $x, $matches)) { // subject contains "-" or numbers, but might also contain others + assertType('non-empty-string', $x); + assertType('array{non-empty-string, decimal-int-string}', $matches); } if (preg_match('/^([3-9]+)$/', $x, $matches)) { @@ -37,7 +43,7 @@ function edgeCases(string $x): void // a required leading minus and digits is always non-falsy if (preg_match('/^(-[0-9]+)$/', $x, $matches)) { - assertType('decimal-int-string&non-falsy-string', $matches[1]); + assertType('non-falsy-string', $matches[1]); } // a minus that is not a leading sign does not yield a decimal integer diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index ea6655fc740..fec138d88a0 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -447,7 +447,7 @@ function (string $s): void { function (string $s): void { if (preg_match('~^([157])$~', $s, $matches) === 1) { - assertType("array{non-falsy-string, '1'|'5'|'7'}", $matches); + assertType("array{decimal-int-string, '1'|'5'|'7'}", $matches); } }; @@ -1007,7 +1007,7 @@ function bug12749e(string $str): void function bug12749f(string $str): void { if (preg_match('/^[0-9]$/', $str, $match)) { - assertType('array{non-empty-string}', $match); // could be numeric-string + assertType('array{decimal-int-string}', $match); } } diff --git a/tests/PHPStan/Type/Regex/RegexExpressionHelperTest.php b/tests/PHPStan/Type/Regex/RegexExpressionHelperTest.php index 872ba255868..c6d6b6ea263 100644 --- a/tests/PHPStan/Type/Regex/RegexExpressionHelperTest.php +++ b/tests/PHPStan/Type/Regex/RegexExpressionHelperTest.php @@ -57,4 +57,24 @@ public function testRemoveDelimitersAndModifiers(string $inputPattern, string $e ); } + public static function dataIsAnchoredPattern(): iterable + { + yield ['/^\d$/', true]; + yield ['/^\d/', false]; + yield ['/\d$/', false]; + yield ['/\d/', false]; + yield ['\d', false]; + } + + #[DataProvider('dataIsAnchoredPattern')] + public function testIsAnchoredPattern(string $regex, bool $expected): void + { + $regexExpressionHelper = self::getContainer()->getByType(RegexExpressionHelper::class); + + $this->assertSame( + $expected, + $regexExpressionHelper->isAnchoredPattern($regex), + ); + } + } From 398b361e6f854a51fe811342d825fcf35a924a10 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 3 Jun 2026 13:20:15 +0200 Subject: [PATCH 6/7] more tests --- tests/PHPStan/Analyser/nsrt/bug-14750.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14750.php b/tests/PHPStan/Analyser/nsrt/bug-14750.php index fc4dc15f027..7e4c78d3453 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14750.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14750.php @@ -6,6 +6,13 @@ function pregMatchDecimalIntStringTypeMatches(string $x): void { + if (preg_match('/^[0-9]+$/', $x)) { + assertType('decimal-int-string', $x); + } + if (preg_match('/^-?[0-9]+$/', $x)) { + assertType('decimal-int-string', $x); + } + if (preg_match('/^(-?[0-9]+)$/', $x, $matches)) { // all chars in string are "-" or numbers assertType('decimal-int-string', $x); assertType('array{decimal-int-string, decimal-int-string}', $matches); From 4f56f2234cfd814bdefa5d987fd70950fc7b4efd Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 3 Jun 2026 13:25:08 +0200 Subject: [PATCH 7/7] Update bug-14750.php --- tests/PHPStan/Analyser/nsrt/bug-14750.php | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-14750.php b/tests/PHPStan/Analyser/nsrt/bug-14750.php index 7e4c78d3453..432e51862a8 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14750.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14750.php @@ -6,12 +6,19 @@ function pregMatchDecimalIntStringTypeMatches(string $x): void { + if (preg_match('/[0-9]/', $x)) { // contains numbers, but might also contain others + assertType('non-empty-string', $x); + } + if (preg_match('/^[0-9]+$/', $x)) { assertType('decimal-int-string', $x); } if (preg_match('/^-?[0-9]+$/', $x)) { assertType('decimal-int-string', $x); } + if (preg_match('/^-?[0-9\w]+$/', $x)) { + assertType('non-empty-string', $x); + } if (preg_match('/^(-?[0-9]+)$/', $x, $matches)) { // all chars in string are "-" or numbers assertType('decimal-int-string', $x);