diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php index 84d9850b39..c9d8c0bef7 100644 --- a/src/Type/Php/RegexArrayShapeMatcher.php +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -7,17 +7,21 @@ use Hoa\Compiler\Llk\TreeNode; use Hoa\Exception\Exception; use Hoa\File\Read; +use Nette\Utils\Strings; use PhpParser\Node\Expr; use PhpParser\Node\Name; use PHPStan\Analyser\Scope; use PHPStan\Php\PhpVersion; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; +use PHPStan\Type\Accessory\AccessoryNumericStringType; use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\IntegerRangeType; +use PHPStan\Type\IntersectionType; use PHPStan\Type\StringType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -126,7 +130,6 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched $trailingOptionals++; } - $valueType = $this->getValueType($flags ?? 0); $onlyOptionalTopLevelGroup = $this->getOnlyOptionalTopLevelGroup($groupList); $onlyTopLevelAlternationId = $this->getOnlyTopLevelAlternationId($groupList); @@ -141,7 +144,6 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched $combiType = $this->buildArrayType( $groupList, - $valueType, $wasMatched, $trailingOptionals, $flags ?? 0, @@ -179,7 +181,6 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched $combiType = $this->buildArrayType( $comboList, - $valueType, $wasMatched, $trailingOptionals, $flags ?? 0, @@ -202,7 +203,6 @@ private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched return $this->buildArrayType( $groupList, - $valueType, $wasMatched, $trailingOptionals, $flags ?? 0, @@ -264,7 +264,6 @@ private function getOnlyTopLevelAlternationId(array $captureGroups): ?int */ private function buildArrayType( array $captureGroups, - Type $valueType, TrinaryLogic $wasMatched, int $trailingOptionals, int $flags, @@ -275,14 +274,14 @@ private function buildArrayType( // first item in matches contains the overall match. $builder->setOffsetValueType( $this->getKeyType(0), - TypeCombinator::removeNull($valueType), + TypeCombinator::removeNull($this->getValueType(new StringType(), $flags)), !$wasMatched->yes(), ); $countGroups = count($captureGroups); $i = 0; foreach ($captureGroups as $captureGroup) { - $groupValueType = $valueType; + $groupValueType = $this->getValueType($captureGroup->getType(), $flags); if (!$wasMatched->yes()) { $optional = true; @@ -299,6 +298,10 @@ private function buildArrayType( } } + if (!$optional && $captureGroup->isOptional() && !$this->containsUnmatchedAsNull($flags)) { + $groupValueType = TypeCombinator::union($groupValueType, new ConstantStringType('')); + } + if ($captureGroup->isNamed()) { $builder->setOffsetValueType( $this->getKeyType($captureGroup->getName()), @@ -333,9 +336,10 @@ private function getKeyType(int|string $key): Type return new ConstantIntegerType($key); } - private function getValueType(int $flags): Type + private function getValueType(Type $baseType, int $flags): Type { - $valueType = new StringType(); + $valueType = $baseType; + $offsetType = IntegerRangeType::fromInterval(0, null); if ($this->containsUnmatchedAsNull($flags)) { $valueType = TypeCombinator::addNull($valueType); @@ -420,6 +424,7 @@ private function walkRegexAst( $inAlternation ? $alternationId : null, $inOptionalQuantification, $parentGroup, + $this->createGroupType($ast), ); $parentGroup = $group; } elseif ($ast->getId() === '#namedcapturing') { @@ -430,6 +435,7 @@ private function walkRegexAst( $inAlternation ? $alternationId : null, $inOptionalQuantification, $parentGroup, + $this->createGroupType($ast), ); $parentGroup = $group; } elseif ($ast->getId() === '#noncapturing') { @@ -534,6 +540,131 @@ private function getQuantificationRange(TreeNode $node): array return [$min, $max]; } + private function createGroupType(TreeNode $group): Type + { + $isNonEmpty = TrinaryLogic::createMaybe(); + $isNumeric = TrinaryLogic::createMaybe(); + $inOptionalQuantification = false; + + $this->walkGroupAst($group, $isNonEmpty, $isNumeric, $inOptionalQuantification); + + $accessories = []; + if ($isNumeric->yes()) { + $accessories[] = new AccessoryNumericStringType(); + } elseif ($isNonEmpty->yes()) { + $accessories[] = new AccessoryNonEmptyStringType(); + } + + if ($accessories !== []) { + $accessories[] = new StringType(); + return new IntersectionType($accessories); + } + + return new StringType(); + } + + private function walkGroupAst(TreeNode $ast, TrinaryLogic &$isNonEmpty, TrinaryLogic &$isNumeric, bool &$inOptionalQuantification): void + { + $children = $ast->getChildren(); + + if ( + $ast->getId() === '#concatenation' + && count($children) > 0 + ) { + $isNonEmpty = TrinaryLogic::createYes(); + } + + if ($ast->getId() === '#quantification') { + [$min] = $this->getQuantificationRange($ast); + + if ($min === 0) { + $inOptionalQuantification = true; + } + if ($min >= 1) { + $isNonEmpty = TrinaryLogic::createYes(); + $inOptionalQuantification = false; + } + } + + if ($ast->getId() === 'token') { + $literalValue = $this->getLiteralValue($ast); + if ($literalValue !== null) { + if (Strings::match($literalValue, '/^\d+$/') === null) { + $isNumeric = TrinaryLogic::createNo(); + } + + if (!$inOptionalQuantification) { + $isNonEmpty = TrinaryLogic::createYes(); + } + } + + if ($ast->getValueToken() === 'character_type') { + if ($ast->getValueValue() === '\d') { + if ($isNumeric->maybe()) { + $isNumeric = TrinaryLogic::createYes(); + } + } else { + $isNumeric = TrinaryLogic::createNo(); + } + + if (!$inOptionalQuantification) { + $isNonEmpty = TrinaryLogic::createYes(); + } + } + } + + if ($ast->getId() === '#range' || $ast->getId() === '#class') { + if ($isNumeric->maybe()) { + $allNumeric = null; + foreach ($children as $child) { + $literalValue = $this->getLiteralValue($child); + + if ($literalValue === null) { + break; + } + + if (Strings::match($literalValue, '/^\d+$/') === null) { + $allNumeric = false; + break; + } + + $allNumeric = true; + } + + if ($allNumeric === true) { + $isNumeric = TrinaryLogic::createYes(); + } + } + + if (!$inOptionalQuantification) { + $isNonEmpty = TrinaryLogic::createYes(); + } + } + + foreach ($children as $child) { + $this->walkGroupAst( + $child, + $isNonEmpty, + $isNumeric, + $inOptionalQuantification, + ); + } + } + + private function getLiteralValue(TreeNode $node): ?string + { + if ($node->getId() === 'token' && $node->getValueToken() === 'literal') { + return $node->getValueValue(); + } + + // literal "-" outside of a character class like '~^((\\d{1,6})-)$~' + if ($node->getId() === 'token' && $node->getValueToken() === 'range') { + return $node->getValueValue(); + } + + return null; + } + private function getPatternType(Expr $patternExpr, Scope $scope): Type { if ($patternExpr instanceof Expr\BinaryOp\Concat) { diff --git a/src/Type/Php/RegexCapturingGroup.php b/src/Type/Php/RegexCapturingGroup.php index 3008e66294..e5ddac62e2 100644 --- a/src/Type/Php/RegexCapturingGroup.php +++ b/src/Type/Php/RegexCapturingGroup.php @@ -2,6 +2,8 @@ namespace PHPStan\Type\Php; +use PHPStan\Type\Type; + class RegexCapturingGroup { @@ -13,6 +15,7 @@ public function __construct( private ?int $alternationId, private bool $inOptionalQuantification, private RegexCapturingGroup|RegexNonCapturingGroup|null $parent, + private Type $type, ) { } @@ -92,4 +95,9 @@ public function getName(): ?string return $this->name; } + public function getType(): Type + { + return $this->type; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php b/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php index 6e768b5c01..3b60727e84 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311-php72.php @@ -8,23 +8,23 @@ function doFoo(string $s) { if (1 === preg_match('/(?\d+)\.(?\d+)(?:\.(?\d+))?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{0: string, major: string, 1: string, minor: string, 2: string, patch?: string, 3?: string}', $matches); + assertType('array{0: string, major: numeric-string, 1: numeric-string, minor: numeric-string, 2: numeric-string, patch?: numeric-string, 3?: numeric-string}', $matches); } } function doUnmatchedAsNull(string $s): void { if (preg_match('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{0: string, 1?: string, 2?: string, 3?: string}', $matches); + assertType('array{0: string, 1?: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, 1?: string, 2?: string, 3?: string}', $matches); + assertType('array{}|array{0: string, 1?: non-empty-string, 2?: non-empty-string, 3?: non-empty-string}', $matches); } // 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?: string}', $matches); + assertType('array{0: string, 1?: non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: string, 1?: string}', $matches); + assertType('array{}|array{0: string, 1?: non-empty-string}', $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-11311.php b/tests/PHPStan/Analyser/nsrt/bug-11311.php index 20d2a4e1bc..e9cde3e771 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-11311.php +++ b/tests/PHPStan/Analyser/nsrt/bug-11311.php @@ -7,26 +7,26 @@ function doFoo(string $s) { if (1 === preg_match('/(?\d+)\.(?\d+)(?:\.(?\d+))?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{0: string, major: string, 1: string, minor: string, 2: string, patch: string|null, 3: string|null}', $matches); + assertType('array{0: string, major: numeric-string, 1: numeric-string, minor: numeric-string, 2: numeric-string, patch: numeric-string|null, 3: numeric-string|null}', $matches); } } function doUnmatchedAsNull(string $s): void { if (preg_match('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{string, string|null, string|null, string|null}', $matches); + assertType('array{string, non-empty-string|null, non-empty-string|null, non-empty-string|null}', $matches); } - assertType('array{}|array{string, string|null, string|null, string|null}', $matches); + assertType('array{}|array{string, non-empty-string|null, non-empty-string|null, non-empty-string|null}', $matches); } // see https://3v4l.org/VeDob 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, string|null}', $matches); + assertType('array{string, non-empty-string|null}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{string, string|null}', $matches); + assertType('array{}|array{string, non-empty-string|null}', $matches); } function bug11331a(string $url):void { @@ -36,7 +36,7 @@ function bug11331a(string $url):void { (?.+) )? (?.+)}mix', $url, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{0: string, a: string|null, 1: string|null, b: string, 2: string}', $matches); + assertType('array{0: string, a: non-empty-string|null, 1: non-empty-string|null, b: non-empty-string, 2: non-empty-string}', $matches); } } @@ -46,7 +46,7 @@ function bug11331b(string $url):void { (?.+) )? (?.+)?}mix', $url, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{0: string, a: string|null, 1: string|null, b: string|null, 2: string|null}', $matches); + assertType('array{0: string, a: non-empty-string|null, 1: non-empty-string|null, b: non-empty-string|null, 2: non-empty-string|null}', $matches); } } @@ -62,20 +62,128 @@ function bug11331c(string $url):void { ([^/]+?) (?:\.git|/)? $}x', $url, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{string, string|null, string|null, string, string}', $matches); + assertType('array{string, non-empty-string|null, non-empty-string|null, non-empty-string, non-empty-string}', $matches); } } class UnmatchedAsNullWithTopLevelAlternation { function doFoo(string $s): void { if (preg_match('/Price: (?:(£)|(€))\d+/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{string, string|null, string|null}', $matches); // could be array{0: string, 1: null, 2: string}|array{0: string, 1: string, 2: null} + assertType('array{string, non-empty-string|null, non-empty-string|null}', $matches); // could be array{0: string, 1: null, 2: non-empty-string}|array{0: string, 1: non-empty-string, 2: null} } } function doBar(string $s): void { if (preg_match('/Price: (?:(£)|(€))?\d+/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{string, string|null, string|null}', $matches); + assertType('array{string, non-empty-string|null, non-empty-string|null}', $matches); } } } + +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{string, numeric-string, numeric-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{string, non-empty-string, numeric-string, numeric-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{string, non-empty-string, numeric-string, non-empty-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{string, numeric-string, numeric-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: string, num: numeric-string, 1: numeric-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{string, numeric-string}', $matches); +}; + +function (string $size): void { + if (preg_match('/ab(\d+\s)e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); + } + assertType('array{string, non-empty-string}', $matches); +}; + +function (string $size): void { + if (preg_match('/ab(\s)e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); + } + assertType('array{string, non-empty-string}', $matches); +}; + +function (string $size): void { + if (preg_match('/ab(\S)e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); + } + assertType('array{string, non-empty-string}', $matches); +}; + +function (string $size): void { + if (preg_match('/ab(\S?)e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); + } + assertType('array{string, string}', $matches); +}; + +function (string $size): void { + if (preg_match('/ab(\S)?e?/', $size, $matches, PREG_UNMATCHED_AS_NULL) !== 1) { + throw new InvalidArgumentException(sprintf('Invalid size "%s"', $size)); + } + assertType('array{string, non-empty-string|null}', $matches); +}; + +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{string, numeric-string}', $matches); +}; + +function (string $s): void { + if (preg_match('/Price: ([2-5])/i', $s, $matches, PREG_UNMATCHED_AS_NULL)) { + assertType('array{string, numeric-string}', $matches); + } +}; + +function (string $s): void { + if (preg_match('/Price: ([2-5A-Z])/i', $s, $matches, PREG_UNMATCHED_AS_NULL)) { + assertType('array{string, non-empty-string}', $matches); + } +}; + +function (string $s): void { + if (preg_match('/^%([0-9]*\$)?[0-9]*\.?[0-9]*([sbdeEfFgGhHouxX])$/', $s, $matches, PREG_UNMATCHED_AS_NULL) === 1) { + assertType('array{string, non-empty-string|null, non-empty-string}', $matches); + } +}; + +function (string $s): void { + if (preg_match('/(?\s*)(?.*)/', $s, $matches, PREG_UNMATCHED_AS_NULL) === 1) { + assertType('array{0: string, whitespace: string, 1: string, value: string, 2: string}', $matches); + } +}; diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php index 0028c9e655..85831749c5 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes.php @@ -12,112 +12,112 @@ function doMatch(string $s): void { assertType('array{}|array{string}', $matches); if (preg_match('/Price: (£|€)\d+/', $s, $matches)) { - assertType('array{string, string}', $matches); + assertType('array{string, non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, non-empty-string}', $matches); if (preg_match('/Price: (£|€)(\d+)/i', $s, $matches)) { - assertType('array{string, string, string}', $matches); + assertType('array{string, non-empty-string, numeric-string}', $matches); } - assertType('array{}|array{string, string, string}', $matches); + assertType('array{}|array{string, non-empty-string, numeric-string}', $matches); if (preg_match(' /Price: (£|€)\d+/ i u', $s, $matches)) { - assertType('array{string, string}', $matches); + assertType('array{string, non-empty-string}', $matches); } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, non-empty-string}', $matches); if (preg_match('(Price: (£|€))i', $s, $matches)) { - assertType('array{string, string, string}', $matches); + assertType('array{string, non-empty-string, non-empty-string}', $matches); } - assertType('array{}|array{string, string, string}', $matches); + assertType('array{}|array{string, non-empty-string, non-empty-string}', $matches); if (preg_match('_foo(.)\_i_i', $s, $matches)) { - assertType('array{string, string}', $matches); + assertType('array{string, non-empty-string}', $matches); } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, non-empty-string}', $matches); if (preg_match('/(a)(b)*(c)(d)*/', $s, $matches)) { - assertType('array{0: string, 1: string, 2: string, 3: string, 4?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, 2: string, 3: non-empty-string, 4?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, 1: string, 2: string, 3: string, 4?: string}', $matches); + assertType('array{}|array{0: string, 1: non-empty-string, 2: string, 3: non-empty-string, 4?: non-empty-string}', $matches); if (preg_match('/(a)(?b)*(c)(d)*/', $s, $matches)) { - assertType('array{0: string, 1: string, name: string, 2: string, 3: string, 4?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, name: string, 2: string, 3: non-empty-string, 4?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, 1: string, name: string, 2: string, 3: string, 4?: string}', $matches); + assertType('array{}|array{0: string, 1: non-empty-string, name: string, 2: string, 3: non-empty-string, 4?: non-empty-string}', $matches); if (preg_match('/(a)(b)*(c)(?d)*/', $s, $matches)) { - assertType('array{0: string, 1: string, 2: string, 3: string, name?: string, 4?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, 2: string, 3: non-empty-string, name?: non-empty-string, 4?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, 1: string, 2: string, 3: string, name?: string, 4?: string}', $matches); + assertType('array{}|array{0: string, 1: non-empty-string, 2: string, 3: non-empty-string, name?: non-empty-string, 4?: non-empty-string}', $matches); if (preg_match('/(a|b)|(?:c)/', $s, $matches)) { - assertType('array{0: string, 1?: string}', $matches); + assertType('array{0: string, 1?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, 1?: string}', $matches); + assertType('array{}|array{0: string, 1?: non-empty-string}', $matches); if (preg_match('/(foo)(bar)(baz)+/', $s, $matches)) { - assertType('array{string, string, string, string}', $matches); + assertType('array{string, non-empty-string, non-empty-string, non-empty-string}', $matches); } - assertType('array{}|array{string, string, string, string}', $matches); + assertType('array{}|array{string, non-empty-string, non-empty-string, non-empty-string}', $matches); if (preg_match('/(foo)(bar)(baz)*/', $s, $matches)) { - assertType('array{0: string, 1: string, 2: string, 3?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, 2: non-empty-string, 3?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, 1: string, 2: string, 3?: string}', $matches); + assertType('array{}|array{0: string, 1: non-empty-string, 2: non-empty-string, 3?: non-empty-string}', $matches); if (preg_match('/(foo)(bar)(baz)?/', $s, $matches)) { - assertType('array{0: string, 1: string, 2: string, 3?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, 2: non-empty-string, 3?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, 1: string, 2: string, 3?: string}', $matches); + assertType('array{}|array{0: string, 1: non-empty-string, 2: non-empty-string, 3?: non-empty-string}', $matches); if (preg_match('/(foo)(bar)(baz){0,3}/', $s, $matches)) { - assertType('array{0: string, 1: string, 2: string, 3?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, 2: non-empty-string, 3?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, 1: string, 2: string, 3?: string}', $matches); + assertType('array{}|array{0: string, 1: non-empty-string, 2: non-empty-string, 3?: non-empty-string}', $matches); if (preg_match('/(foo)(bar)(baz){2,3}/', $s, $matches)) { - assertType('array{string, string, string, string}', $matches); + assertType('array{string, non-empty-string, non-empty-string, non-empty-string}', $matches); } - assertType('array{}|array{string, string, string, string}', $matches); + assertType('array{}|array{string, non-empty-string, non-empty-string, non-empty-string}', $matches); if (preg_match('/(foo)(bar)(baz){2}/', $s, $matches)) { - assertType('array{string, string, string, string}', $matches); + assertType('array{string, non-empty-string, non-empty-string, non-empty-string}', $matches); } - assertType('array{}|array{string, string, string, string}', $matches); + assertType('array{}|array{string, non-empty-string, non-empty-string, non-empty-string}', $matches); } function doNonCapturingGroup(string $s): void { if (preg_match('/Price: (?:£|€)(\d+)/', $s, $matches)) { - assertType('array{string, string}', $matches); + assertType('array{string, numeric-string}', $matches); } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, numeric-string}', $matches); } function doNamedSubpattern(string $s): void { if (preg_match('/\w-(?P\d+)-(\w)/', $s, $matches)) { - assertType('array{0: string, num: string, 1: string, 2: string}', $matches); + assertType('array{0: string, num: numeric-string, 1: numeric-string, 2: non-empty-string}', $matches); } - assertType('array{}|array{0: string, num: string, 1: string, 2: string}', $matches); + assertType('array{}|array{0: string, num: numeric-string, 1: numeric-string, 2: non-empty-string}', $matches); if (preg_match('/^(?\S+::\S+)/', $s, $matches)) { - assertType('array{0: string, name: string, 1: string}', $matches); + assertType('array{0: string, name: non-empty-string, 1: non-empty-string}', $matches); } - assertType('array{}|array{0: string, name: string, 1: string}', $matches); + assertType('array{}|array{0: string, name: non-empty-string, 1: non-empty-string}', $matches); if (preg_match('/^(?\S+::\S+)(?:(? with data set (?:#\d+|"[^"]+"))\s\()?/', $s, $matches)) { - assertType('array{0: string, name: string, 1: string, dataname?: string, 2?: string}', $matches); + assertType('array{0: string, name: non-empty-string, 1: non-empty-string, dataname?: non-empty-string, 2?: non-empty-string}', $matches); } - assertType('array{}|array{0: string, name: string, 1: string, dataname?: string, 2?: string}', $matches); + assertType('array{}|array{0: string, name: non-empty-string, 1: non-empty-string, dataname?: non-empty-string, 2?: non-empty-string}', $matches); } function doOffsetCapture(string $s): void { if (preg_match('/(foo)(bar)(baz)/', $s, $matches, PREG_OFFSET_CAPTURE)) { - assertType('array{array{string, int<0, max>}, array{string, int<0, max>}, array{string, int<0, max>}, array{string, int<0, max>}}', $matches); + assertType('array{array{string, int<0, max>}, array{non-empty-string, int<0, max>}, array{non-empty-string, int<0, max>}, array{non-empty-string, int<0, max>}}', $matches); } - assertType('array{}|array{array{string, int<0, max>}, array{string, int<0, max>}, array{string, int<0, max>}, array{string, int<0, max>}}', $matches); + assertType('array{}|array{array{string, int<0, max>}, array{non-empty-string, int<0, max>}, array{non-empty-string, int<0, max>}, array{non-empty-string, int<0, max>}}', $matches); } function doUnknownFlags(string $s, int $flags): void { @@ -154,14 +154,14 @@ function doMultipleConsecutiveCaptureGroupsWithSameNameWithModifier(string $s): // https://github.com/hoaproject/Regex/issues/31 function hoaBug31(string $s): void { if (preg_match('/([\w-])/', $s, $matches)) { - assertType('array{string, string}', $matches); + assertType('array{string, non-empty-string}', $matches); } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, non-empty-string}', $matches); if (preg_match('/\w-(\d+)-(\w)/', $s, $matches)) { - assertType('array{string, string, string}', $matches); + assertType('array{string, numeric-string, non-empty-string}', $matches); } - assertType('array{}|array{string, string, string}', $matches); + assertType('array{}|array{string, numeric-string, non-empty-string}', $matches); } // https://github.com/phpstan/phpstan/issues/10855#issuecomment-2044323638 @@ -235,21 +235,21 @@ function testUnionPattern(string $s): void $pattern = '/Price: (\d+)(\d+)(\d+)/'; } if (preg_match($pattern, $s, $matches)) { - assertType('array{string, string, string, string}|array{string, string}', $matches); + assertType('array{string, numeric-string, numeric-string, numeric-string}|array{string, numeric-string}', $matches); } - assertType('array{}|array{string, string, string, string}|array{string, string}', $matches); + assertType('array{}|array{string, numeric-string, numeric-string, numeric-string}|array{string, numeric-string}', $matches); } function doFoo(string $row): void { if (preg_match('~^(a(b))$~', $row, $matches) === 1) { - assertType('array{string, string, string}', $matches); + assertType('array{string, non-empty-string, non-empty-string}', $matches); } if (preg_match('~^(a(b)?)$~', $row, $matches) === 1) { - assertType('array{0: string, 1: string, 2?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, 2?: non-empty-string}', $matches); } if (preg_match('~^(a(b)?)?$~', $row, $matches) === 1) { - assertType('array{0: string, 1?: string, 2?: string}', $matches); + assertType('array{0: string, 1?: non-empty-string, 2?: non-empty-string}', $matches); } } @@ -259,7 +259,7 @@ function doFoo2(string $row): void return; } - assertType('array{0: string, 1: string, branchCode: string, 2: string, accountNumber: string, 3: string, bankCode: string, 4: string}', $matches); + assertType("array{0: string, 1: string, branchCode: ''|numeric-string, 2: ''|numeric-string, accountNumber: numeric-string, 3: numeric-string, bankCode: numeric-string, 4: numeric-string}", $matches); } function doFoo3(string $row): void @@ -268,79 +268,79 @@ function doFoo3(string $row): void return; } - assertType('array{string, string, string, string, string, string, string}', $matches); + assertType('array{string, non-empty-string, non-empty-string, numeric-string, numeric-string, numeric-string, numeric-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{string, string, string, string, string}|array{string}', $matches); + assertType('array{string, non-empty-string, numeric-string, numeric-string, non-empty-string}|array{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{string, string, string}|array{string}', $matches); + assertType('array{string, non-empty-string, numeric-string}|array{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: string, 1: string, 2?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, 2?: numeric-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: string, 1?: string, 2?: string}', $matches); + assertType('array{0: string, 1?: non-empty-string, 2?: numeric-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{string, string, string}', $matches); + assertType('array{string, non-empty-string, numeric-string}', $matches); }; 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?: string, 2?: string}', $matches); + assertType('array{0: string, 1?: non-empty-string, 2?: non-empty-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{0: string, 1: string, 2: string, 3?: string, 4?: string}', $matches); + assertType('array{0: string, 1: numeric-string, 2: numeric-string, 3?: numeric-string, 4?: numeric-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{0: string, 1: string, 2: string, 3?: string, 4?: string}|array{string}', $matches); + assertType('array{0: string, 1: numeric-string, 2: numeric-string, 3?: numeric-string, 4?: numeric-string}|array{string}', $matches); }; function (string $size): void { if (preg_match('~\{(?:(include)\\s+(?:[$]?\\w+(?£|€)\d+/', $s, $matches)) { - assertType('array{0: string, currency: string, 1: string}', $matches); + assertType('array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: string, currency: string, 1: string}', $matches); + assertType('array{}|array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); } function bug11323b(string $s): void { if (preg_match('/Price: (?£|€)\d+/', $s, $matches)) { - assertType('array{0: string, currency: string, 1: string}', $matches); + assertType('array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: string, currency: string, 1: string}', $matches); + assertType('array{}|array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); } function unmatchedAsNullWithMandatoryGroup(string $s): void { if (preg_match('/Price: (?£|€)\d+/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { - assertType('array{0: string, currency: string, 1: string}', $matches); + assertType('array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: string, currency: string, 1: string}', $matches); + assertType('array{}|array{0: string, currency: non-empty-string, 1: non-empty-string}', $matches); } function (string $s): void { if (preg_match('{' . preg_quote('xxx') . '(z)}', $s, $matches)) { - assertType('array{string, string}', $matches); + assertType('array{string, non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, non-empty-string}', $matches); }; function (string $s): void { if (preg_match('{' . preg_quote($s) . '(z)}', $s, $matches)) { - assertType('array{string, string}', $matches); + assertType('array{string, non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, non-empty-string}', $matches); }; function (string $s): void { if (preg_match('/' . preg_quote($s, '/') . '(\d)/', $s, $matches)) { - assertType('array{string, string}', $matches); + assertType('array{string, numeric-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, numeric-string}', $matches); }; function (string $s): void { if (preg_match('{' . preg_quote($s) . '(z)' . preg_quote($s) . '(?:abc)(def)?}', $s, $matches)) { - assertType('array{0: string, 1: string, 2?: string}', $matches); + assertType('array{0: string, 1: non-empty-string, 2?: non-empty-string}', $matches); } else { assertType('array{}', $matches); } - assertType('array{}|array{0: string, 1: string, 2?: string}', $matches); + assertType('array{}|array{0: string, 1: non-empty-string, 2?: non-empty-string}', $matches); }; function (string $s, $mixed): void { @@ -442,3 +442,33 @@ function (string $s, $mixed): void { } assertType('array', $matches); }; + +function (string $s): void { + if (preg_match('/^%([0-9]*\$)?[0-9]*\.?[0-9]*([sbdeEfFgGhHouxX])$/', $s, $matches) === 1) { + assertType('array{string, string, non-empty-string}', $matches); + } +}; + +function (string $s): void { + if (preg_match('~^((\\d{1,6})-)$~', $s, $matches) === 1) { + assertType("array{string, non-empty-string, numeric-string}", $matches); + } +}; + +function (string $s): void { + if (preg_match('~^((\\d{1,6}).)$~', $s, $matches) === 1) { + assertType("array{string, non-empty-string, numeric-string}", $matches); + } +}; + +function (string $s): void { + if (preg_match('~^([157])$~', $s, $matches) === 1) { + assertType("array{string, numeric-string}", $matches); + } +}; + +function (string $s): void { + if (preg_match('~^([157XY])$~', $s, $matches) === 1) { + assertType("array{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 d3f0421457..2a7f5cb923 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php @@ -7,7 +7,7 @@ function doOffsetCaptureWithUnmatchedNull(string $s): void { // see https://3v4l.org/07rBO#v8.2.9 if (preg_match('/(foo)(bar)(baz)/', $s, $matches, PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL)) { - assertType('array{array{string|null, int<-1, max>}, array{string|null, int<-1, max>}, array{string|null, int<-1, max>}, array{string|null, int<-1, max>}}', $matches); + assertType('array{array{string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}}', $matches); } - assertType('array{}|array{array{string|null, int<-1, max>}, array{string|null, int<-1, max>}, array{string|null, int<-1, max>}, array{string|null, int<-1, max>}}', $matches); + assertType('array{}|array{array{string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}, array{non-empty-string|null, int<-1, max>}}', $matches); } diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php index 737f7a8c8d..af50a43064 100644 --- a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php @@ -8,12 +8,12 @@ // https://php.watch/versions/8.2/preg-n-no-capture-modifier function doNonAutoCapturingFlag(string $s): void { if (preg_match('/(\d+)/n', $s, $matches)) { - assertType('array{string, string}', $matches); // should be 'array{string}' + assertType('array{string, numeric-string}', $matches); // should be 'array{string}' } - assertType('array{}|array{string, string}', $matches); + assertType('array{}|array{string, numeric-string}', $matches); if (preg_match('/(\d+)(?P\d+)/n', $s, $matches)) { - assertType('array{0: string, 1: string, num: string, 2: string}', $matches); + assertType('array{0: string, 1: numeric-string, num: numeric-string, 2: numeric-string}', $matches); } - assertType('array{}|array{0: string, 1: string, num: string, 2: string}', $matches); + assertType('array{}|array{0: string, 1: numeric-string, num: numeric-string, 2: numeric-string}', $matches); }