diff --git a/composer.json b/composer.json index ceb38f0794..77fca1bab8 100644 --- a/composer.json +++ b/composer.json @@ -82,6 +82,9 @@ "composer/ca-bundle": [ "patches/cloudflare-ca.patch" ], + "hoa/regex": [ + "patches/Grammar.patch" + ], "hoa/iterator": [ "patches/Buffer.patch", "patches/Lookahead.patch" diff --git a/composer.lock b/composer.lock index f18481ad71..5c135e2347 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "db09e230b5029b6247349873fc13819a", + "content-hash": "30f0be2f5f4d8a9074cff904026ec2a6", "packages": [ { "name": "clue/ndjson-react", diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index 788cec4791..ef0fba19f4 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -54,5 +54,6 @@ parameters: paramOutType: true pure: true checkParameterCastableToStringFunctions: true + narrowPregMatches: true stubFiles: - ../stubs/bleedingEdge/Rule.stub diff --git a/conf/config.neon b/conf/config.neon index 061a65eb02..b7521b88aa 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -89,6 +89,7 @@ parameters: paramOutType: false pure: false checkParameterCastableToStringFunctions: false + narrowPregMatches: false fileExtensions: - php checkAdvancedIsset: false @@ -284,6 +285,10 @@ conditionalTags: phpstan.parser.richParserNodeVisitor: %featureToggles.curlSetOptTypes% PHPStan\Parser\TypeTraverserInstanceofVisitor: phpstan.parser.richParserNodeVisitor: %featureToggles.instanceofType% + PHPStan\Type\Php\PregMatchTypeSpecifyingExtension: + phpstan.typeSpecifier.functionTypeSpecifyingExtension: %featureToggles.narrowPregMatches% + PHPStan\Type\Php\PregMatchParameterOutTypeExtension: + phpstan.functionParameterOutTypeExtension: %featureToggles.narrowPregMatches% services: - @@ -1465,6 +1470,15 @@ services: tags: - phpstan.dynamicFunctionThrowTypeExtension + - + class: PHPStan\Type\Php\PregMatchTypeSpecifyingExtension + + - + class: PHPStan\Type\Php\PregMatchParameterOutTypeExtension + + - + class: PHPStan\Type\Php\RegexArrayShapeMatcher + - class: PHPStan\Type\Php\ReflectionClassConstructorThrowTypeExtension tags: diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index e4bf73db35..0a63268a1b 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -84,6 +84,7 @@ parametersSchema: paramOutType: bool() pure: bool() checkParameterCastableToStringFunctions: bool() + narrowPregMatches: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/patches/Grammar.patch b/patches/Grammar.patch new file mode 100644 index 0000000000..f35bbf3b9f --- /dev/null +++ b/patches/Grammar.patch @@ -0,0 +1,37 @@ +--- Grammar.pp 2024-05-18 12:15:53 ++++ Grammar.pp.fix 2024-05-18 12:15:05 +@@ -109,7 +109,7 @@ + // Please, see PCRESYNTAX(3), General Category properties, PCRE special category + // properties and script names for \p{} and \P{}. + %token character_type \\([CdDhHNRsSvVwWX]|[pP]{[^}]+}) +-%token anchor \\(bBAZzG)|\^|\$ ++%token anchor \\([bBAZzG])|\^|\$ + %token match_point_reset \\K + %token literal \\.|. + +@@ -168,7 +168,7 @@ + ::negative_class_:: #negativeclass + | ::class_:: + ) +- ( range() | literal() )+ ++ ( | range() | literal() )+ + ::_class:: + + #range: +@@ -178,7 +178,7 @@ + capturing() + | literal() + +-capturing: ++#capturing: + ::comment_:: ? ::_comment:: #comment + | ( + ::named_capturing_:: ::_named_capturing:: #namedcapturing +@@ -191,6 +191,7 @@ + + literal: + ++ | + | + | + | diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index f54a79f35e..faba735c4e 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -1989,6 +1989,22 @@ public function resolveIdentical(Expr\BinaryOp\Identical $expr, Scope $scope, Ty $unwrappedRightExpr = $rightExpr->getExpr(); } $rightType = $scope->getType($rightExpr); + + if ( + $context->true() + && $unwrappedLeftExpr instanceof FuncCall + && $unwrappedLeftExpr->name instanceof Name + && $unwrappedLeftExpr->name->toLowerString() === 'preg_match' + && (new ConstantIntegerType(1))->isSuperTypeOf($rightType)->yes() + ) { + return $this->specifyTypesInCondition( + $scope, + $leftExpr, + $context, + $rootExpr, + ); + } + if ( $context->true() && $unwrappedLeftExpr instanceof FuncCall diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 6a1fe74bf2..86f8c29195 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -287,4 +287,12 @@ public function supportsNeverReturnTypeInArrowFunction(): bool return $this->versionId >= 80200; } + // see https://www.php.net/manual/en/migration74.incompatible.php#migration74.incompatible.pcre + public function returnsPregUnmatchedCapturingGroups(): bool + { + // When PREG_UNMATCHED_AS_NULL mode is used, trailing unmatched capturing groups will now also be set to null (or [null, -1] if offset capture is enabled). + // This means that the size of the $matches will always be the same. + return $this->versionId >= 70400; + } + } diff --git a/src/Type/Php/PregMatchParameterOutTypeExtension.php b/src/Type/Php/PregMatchParameterOutTypeExtension.php new file mode 100644 index 0000000000..2a86bbf864 --- /dev/null +++ b/src/Type/Php/PregMatchParameterOutTypeExtension.php @@ -0,0 +1,51 @@ +getName()), ['preg_match'], true) && $parameter->getName() === 'matches'; + } + + public function getParameterOutTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $funcCall->getArgs(); + $patternArg = $args[0] ?? null; + $matchesArg = $args[2] ?? null; + $flagsArg = $args[3] ?? null; + + if ( + $patternArg === null || $matchesArg === null + ) { + return null; + } + + $patternType = $scope->getType($patternArg->value); + $flagsType = null; + if ($flagsArg !== null) { + $flagsType = $scope->getType($flagsArg->value); + } + + return $this->regexShapeMatcher->matchType($patternType, $flagsType, TrinaryLogic::createMaybe()); + } + +} diff --git a/src/Type/Php/PregMatchTypeSpecifyingExtension.php b/src/Type/Php/PregMatchTypeSpecifyingExtension.php new file mode 100644 index 0000000000..49d82df6f0 --- /dev/null +++ b/src/Type/Php/PregMatchTypeSpecifyingExtension.php @@ -0,0 +1,78 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool + { + return in_array(strtolower($functionReflection->getName()), ['preg_match'], true) && !$context->null(); + } + + public function specifyTypes(FunctionReflection $functionReflection, FuncCall $node, Scope $scope, TypeSpecifierContext $context): SpecifiedTypes + { + $args = $node->getArgs(); + $patternArg = $args[0] ?? null; + $matchesArg = $args[2] ?? null; + $flagsArg = $args[3] ?? null; + + if ( + $patternArg === null || $matchesArg === null + ) { + return new SpecifiedTypes(); + } + + $patternType = $scope->getType($patternArg->value); + $flagsType = null; + if ($flagsArg !== null) { + $flagsType = $scope->getType($flagsArg->value); + } + + $matchedType = $this->regexShapeMatcher->matchType($patternType, $flagsType, TrinaryLogic::createFromBoolean($context->true())); + if ($matchedType === null) { + return new SpecifiedTypes(); + } + + $overwrite = false; + if ($context->false()) { + $overwrite = true; + $context = $context->negate(); + } + + return $this->typeSpecifier->create( + $matchesArg->value, + $matchedType, + $context, + $overwrite, + $scope, + $node, + ); + } + +} diff --git a/src/Type/Php/RegexArrayShapeMatcher.php b/src/Type/Php/RegexArrayShapeMatcher.php new file mode 100644 index 0000000000..018dd82162 --- /dev/null +++ b/src/Type/Php/RegexArrayShapeMatcher.php @@ -0,0 +1,238 @@ +no()) { + return new ConstantArrayType([], []); + } + + if ( + !$this->phpVersion->returnsPregUnmatchedCapturingGroups() + ) { + return null; + } + + $constantStrings = $patternType->getConstantStrings(); + if (count($constantStrings) === 0) { + return null; + } + + $flags = null; + if ($flagsType !== null) { + if ( + !$flagsType instanceof ConstantIntegerType + || !in_array($flagsType->getValue(), [PREG_OFFSET_CAPTURE, PREG_UNMATCHED_AS_NULL, PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL], true) + ) { + return null; + } + + $flags = $flagsType->getValue(); + } + + $matchedTypes = []; + foreach ($constantStrings as $constantString) { + $matched = $this->matchRegex($constantString->getValue(), $flags, $wasMatched); + if ($matched === null) { + return null; + } + + $matchedTypes[] = $matched; + } + + return TypeCombinator::union(...$matchedTypes); + } + + /** + * @param int-mask|null $flags + */ + private function matchRegex(string $regex, ?int $flags, TrinaryLogic $wasMatched): ?Type + { + // add one capturing group to the end so all capture group keys + // are present in the $matches + // see https://3v4l.org/sOXbn, https://3v4l.org/3SdDM + $captureGroupsRegex = Strings::replace($regex, '~.[a-z\s]*$~i', '|(?)$0'); + + try { + $matches = Strings::match('', $captureGroupsRegex, PREG_UNMATCHED_AS_NULL); + if ($matches === null) { + return null; + } + } catch (RegexpException) { + return null; + } + + unset($matches[array_key_last($matches)]); + unset($matches['phpstanNamedCaptureGroupLast']); + + $remainingNonOptionalGroupCount = $this->countNonOptionalGroups($regex); + if ($remainingNonOptionalGroupCount === null) { + // regex could not be parsed by Hoa/Regex + return null; + } + + $builder = ConstantArrayTypeBuilder::createEmpty(); + $valueType = $this->getValueType($flags ?? 0); + + foreach (array_keys($matches) as $key) { + if ($key === 0) { + // first item in matches contains the overall match. + $builder->setOffsetValueType( + $this->getKeyType($key), + TypeCombinator::removeNull($valueType), + !$wasMatched->yes(), + ); + + continue; + } + + if (!$wasMatched->yes()) { + $optional = true; + } else { + $optional = $remainingNonOptionalGroupCount <= 0; + + if (is_int($key)) { + $remainingNonOptionalGroupCount--; + } + } + + $builder->setOffsetValueType( + $this->getKeyType($key), + $valueType, + $optional, + ); + } + + return $builder->getArray(); + } + + private function getKeyType(int|string $key): Type + { + if (is_string($key)) { + return new ConstantStringType($key); + } + + return new ConstantIntegerType($key); + } + + private function getValueType(int $flags): Type + { + $valueType = new StringType(); + $offsetType = IntegerRangeType::fromInterval(0, null); + if (($flags & PREG_UNMATCHED_AS_NULL) !== 0) { + $valueType = TypeCombinator::addNull($valueType); + // unmatched groups return -1 as offset + $offsetType = IntegerRangeType::fromInterval(-1, null); + } + + if (($flags & PREG_OFFSET_CAPTURE) !== 0) { + $builder = ConstantArrayTypeBuilder::createEmpty(); + + $builder->setOffsetValueType( + new ConstantIntegerType(0), + $valueType, + ); + $builder->setOffsetValueType( + new ConstantIntegerType(1), + $offsetType, + ); + + return $builder->getArray(); + } + + return $valueType; + } + + private function countNonOptionalGroups(string $regex): ?int + { + if (self::$parser === null) { + /** @throws void */ + self::$parser = Llk::load(new Read('hoa://Library/Regex/Grammar.pp')); + } + + try { + $ast = self::$parser->parse($regex); + } catch ( Exception) { // @phpstan-ignore catch.notThrowable + return null; + } + + return $this->walkRegexAst($ast, 0, 0); + } + + private function walkRegexAst(TreeNode $ast, int $inAlternation, int $inOptionalQuantification): int + { + if ( + in_array($ast->getId(), ['#capturing', '#namedcapturing'], true) + && !($inAlternation > 0 || $inOptionalQuantification > 0) + ) { + return 1; + } + + if ($ast->getId() === '#alternation') { + $inAlternation++; + } + + if ($ast->getId() === '#quantification') { + $lastChild = $ast->getChild($ast->getChildrenNumber() - 1); + $value = $lastChild->getValue(); + + if ($value['token'] === 'n_to_m' && str_contains($value['value'], '{0,')) { + $inOptionalQuantification++; + } elseif ($value['token'] === 'zero_or_one') { + $inOptionalQuantification++; + } elseif ($value['token'] === 'zero_or_more') { + $inOptionalQuantification++; + } + } + + $count = 0; + foreach ($ast->getChildren() as $child) { + $count += $this->walkRegexAst($child, $inAlternation, $inOptionalQuantification); + } + + return $count; + } + +} diff --git a/src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php b/src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php index 8aba836493..4515867fd7 100644 --- a/src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/SprintfFunctionDynamicReturnTypeExtension.php @@ -7,6 +7,7 @@ use PHPStan\Internal\CombinationsHelper; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\InitializerExprTypeResolver; +use PHPStan\ShouldNotHappenException; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Accessory\AccessoryNonFalsyStringType; use PHPStan\Type\Accessory\AccessoryNumericStringType; @@ -54,7 +55,7 @@ public function getTypeFromFunctionCall( foreach ($formatType->getConstantStrings() as $constantString) { // The printf format is %[argnum$][flags][width][.precision] if (preg_match('/^%([0-9]*\$)?[0-9]*\.?[0-9]*([sbdeEfFgGhHouxX])$/', $constantString->getValue(), $matches) === 1) { - if (array_key_exists(1, $matches) && ($matches[1] !== '')) { + if ($matches[1] !== '') { // invalid positional argument if ($matches[1] === '0$') { return null; @@ -73,6 +74,10 @@ public function getTypeFromFunctionCall( // of stringy type, then the return value will be of the same type $checkArgType = $scope->getType($args[$checkArg]->value); + if (!array_key_exists(2, $matches)) { + throw new ShouldNotHappenException(); + } + if ($matches[2] === 's' && $checkArgType->isString()->yes()) { $singlePlaceholderEarlyReturn = $checkArgType; } elseif ($matches[2] !== 's') { diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index c6a73b060b..5fc2c17466 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -302,7 +302,7 @@ public function dataAssignInIf(): array $testScope, 'matches', TrinaryLogic::createYes(), - 'array', + PHP_VERSION_ID <= 80000 ? 'array' : 'array{0?: string}', ], [ $testScope, @@ -343,7 +343,7 @@ public function dataAssignInIf(): array $testScope, 'matches2', TrinaryLogic::createMaybe(), - 'array', + PHP_VERSION_ID <= 80000 ? 'array' : 'array{0?: string}', ], [ $testScope, @@ -355,13 +355,19 @@ public function dataAssignInIf(): array $testScope, 'matches3', TrinaryLogic::createYes(), - 'array', + PHP_VERSION_ID <= 80000 ? 'array' : 'array{0?: string}', ], [ $testScope, 'matches4', TrinaryLogic::createMaybe(), - 'array', + PHP_VERSION_ID <= 80000 ? + ( + PHP_VERSION_ID < 70400 ? + 'array' : + 'array{}|array{string}' + ) + : 'array{}|array{string}', ], [ $testScope, @@ -415,7 +421,7 @@ public function dataAssignInIf(): array $testScope, 'ternaryMatches', TrinaryLogic::createYes(), - 'array', + PHP_VERSION_ID <= 80000 ? 'array' : 'array{0?: string}', ], [ $testScope, diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 3a1bf98033..5a0f3e8fff 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -172,6 +172,10 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/../Rules/Comparison/data/bug-9499.php'); } + if (PHP_VERSION_ID >= 70300 && PHP_VERSION_ID < 70400) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/preg_match_shapes_php73.php'); + } + yield from $this->gatherAssertTypes(__DIR__ . '/../Rules/PhpDoc/data/bug-8609-function.php'); yield from $this->gatherAssertTypes(__DIR__ . '/../Rules/Comparison/data/bug-5365.php'); yield from $this->gatherAssertTypes(__DIR__ . '/../Rules/Comparison/data/bug-6551.php'); diff --git a/tests/PHPStan/Analyser/ParamOutTypeTest.php b/tests/PHPStan/Analyser/ParamOutTypeTest.php index b29b6fbd0c..5c655fa069 100644 --- a/tests/PHPStan/Analyser/ParamOutTypeTest.php +++ b/tests/PHPStan/Analyser/ParamOutTypeTest.php @@ -3,12 +3,19 @@ namespace PHPStan\Analyser; use PHPStan\Testing\TypeInferenceTestCase; +use const PHP_VERSION_ID; class ParamOutTypeTest extends TypeInferenceTestCase { public function dataFileAsserts(): iterable { + if (PHP_VERSION_ID < 80000) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/param-out-php7.php'); + } + if (PHP_VERSION_ID >= 80000) { + yield from $this->gatherAssertTypes(__DIR__ . '/data/param-out-php8.php'); + } yield from $this->gatherAssertTypes(__DIR__ . '/data/param-out.php'); } diff --git a/tests/PHPStan/Analyser/data/param-out-php7.php b/tests/PHPStan/Analyser/data/param-out-php7.php new file mode 100644 index 0000000000..48b9b12015 --- /dev/null +++ b/tests/PHPStan/Analyser/data/param-out-php7.php @@ -0,0 +1,23 @@ +>', $matches); + + preg_match_all('/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/', $input, $matches, PREG_SET_ORDER); + assertType('list>', $matches); + + preg_match('/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/', $input, $matches, PREG_UNMATCHED_AS_NULL); + assertType("array", $matches); +} + +function testMatch() { + preg_match('#.*#', 'foo', $matches); + assertType('array', $matches); +} + + diff --git a/tests/PHPStan/Analyser/data/param-out-php8.php b/tests/PHPStan/Analyser/data/param-out-php8.php new file mode 100644 index 0000000000..fc45c93dc7 --- /dev/null +++ b/tests/PHPStan/Analyser/data/param-out-php8.php @@ -0,0 +1,22 @@ +>', $matches); + + preg_match_all('/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/', $input, $matches, PREG_SET_ORDER); + assertType('list>', $matches); + + preg_match('/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/', $input, $matches, PREG_UNMATCHED_AS_NULL); + assertType("array{0?: string}", $matches); +} + +function testMatch() { + preg_match('#.*#', 'foo', $matches); + assertType('array{0?: string}', $matches); +} + diff --git a/tests/PHPStan/Analyser/data/param-out.php b/tests/PHPStan/Analyser/data/param-out.php index 88cd9bf14d..f5c889400a 100644 --- a/tests/PHPStan/Analyser/data/param-out.php +++ b/tests/PHPStan/Analyser/data/param-out.php @@ -283,17 +283,6 @@ function fooScanf(): void assertType('float|int|string|null', $p2); } -function fooMatch(string $input): void { - preg_match_all('/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/', $input, $matches, PREG_PATTERN_ORDER); - assertType('array>', $matches); - - preg_match_all('/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/', $input, $matches, PREG_SET_ORDER); - assertType('list>', $matches); - - preg_match('/@[a-z\d](?:[a-z\d]|-(?=[a-z\d])){0,38}(?!\w)/', $input, $matches, PREG_UNMATCHED_AS_NULL); - assertType("array", $matches); -} - function fooParams(ExtendsFooBar $subX, float $x1, float $y1) { $subX->renamedParams($x1, $y1); @@ -316,11 +305,6 @@ function fooDateTime(\SplFileObject $splFileObject, ?string $wouldBlock) { assertType('string', $wouldBlock); } -function testMatch() { - preg_match('#.*#', 'foo', $matches); - assertType('array', $matches); -} - function testParseStr() { $str="first=value&arr[]=foo+bar&arr[]=baz"; parse_str($str, $output); diff --git a/tests/PHPStan/Analyser/data/preg_match_shapes_php73.php b/tests/PHPStan/Analyser/data/preg_match_shapes_php73.php new file mode 100644 index 0000000000..2e37134ac7 --- /dev/null +++ b/tests/PHPStan/Analyser/data/preg_match_shapes_php73.php @@ -0,0 +1,14 @@ +', $matches); + } else { + assertType('array{}', $matches); + } + assertType('array', $matches); +} diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php74.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php74.php new file mode 100644 index 0000000000..c5f9e9ded6 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php74.php @@ -0,0 +1,197 @@ += 7.4 + +namespace PregMatchShapes; + +use function PHPStan\Testing\assertType; + + +function doMatch(string $s): void { + if (preg_match('/Price: /i', $s, $matches)) { + assertType('array{string}', $matches); + } + assertType('array{}|array{string}', $matches); + + if (preg_match('/Price: (£|€)\d+/', $s, $matches)) { + assertType('array{string, string}', $matches); + } else { + assertType('array{}', $matches); + } + assertType('array{}|array{string, string}', $matches); + + if (preg_match('/Price: (£|€)(\d+)/i', $s, $matches)) { + assertType('array{string, string, string}', $matches); + } + assertType('array{}|array{string, string, string}', $matches); + + if (preg_match(' /Price: (£|€)\d+/ i u', $s, $matches)) { + assertType('array{string, string}', $matches); + } + assertType('array{}|array{string, string}', $matches); + + if (preg_match('(Price: (£|€))i', $s, $matches)) { + assertType('array{string, string}', $matches); + } + assertType('array{}|array{string, string}', $matches); + + if (preg_match('_foo(.)\_i_i', $s, $matches)) { + assertType('array{string, string}', $matches); + } + assertType('array{}|array{string, 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{}|array{0: string, 1: string, 2: string, 3?: string, 4?: string}', $matches); + + if (preg_match('/(a|b)|(?:c)/', $s, $matches)) { + assertType('array{0: string, 1?: string}', $matches); + } + assertType('array{}|array{0: string, 1?: string}', $matches); + + if (preg_match('/(foo)(bar)(baz)+/', $s, $matches)) { + assertType('array{string, string, string, string}', $matches); + } + assertType('array{}|array{string, string, string, string}', $matches); + + if (preg_match('/(foo)(bar)(baz)*/', $s, $matches)) { + assertType('array{0: string, 1: string, 2: string, 3?: string}', $matches); + } + assertType('array{}|array{0: string, 1: string, 2: string, 3?: string}', $matches); + + if (preg_match('/(foo)(bar)(baz)?/', $s, $matches)) { + assertType('array{0: string, 1: string, 2: string, 3?: string}', $matches); + } + assertType('array{}|array{0: string, 1: string, 2: string, 3?: 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{}|array{0: string, 1: string, 2: string, 3?: string}', $matches); + + if (preg_match('/(foo)(bar)(baz){2,3}/', $s, $matches)) { + assertType('array{string, string, string, string}', $matches); + } + assertType('array{}|array{string, string, string, string}', $matches); +} + +function doNonCapturingGroup(string $s): void { + if (preg_match('/Price: (?:£|€)(\d+)/', $s, $matches)) { + assertType('array{string, string}', $matches); + } + assertType('array{}|array{string, string}', $matches); +} + +function doNamedSubpattern(string $s): void { + if (preg_match('/\w-(?P\d+)-(\w)/', $s, $matches)) { + // could be assertType('array{0: string, num: string, 1: string, 2: string, 3: string}', $matches); + assertType('array', $matches); + } + assertType('array', $matches); + + if (preg_match('/^(?\S+::\S+)/', $s, $matches)) { + assertType('array{0: string, name: string, 1: string}', $matches); + } + assertType('array{}|array{0: string, name: string, 1: 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{}|array{0: string, name: string, 1: string, dataname?: string, 2?: 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{array{string, int<0, max>}, array{string, int<0, max>}, array{string, int<0, max>}, array{string, int<0, max>}}', $matches); +} + +function doUnmatchedAsNull(string $s): void { + if (preg_match('/(foo)?(bar)?(baz)?/', $s, $matches, PREG_UNMATCHED_AS_NULL)) { + assertType('array{0: string, 1?: string|null, 2?: string|null, 3?: string|null}', $matches); + } + assertType('array{}|array{0: string, 1?: string|null, 2?: string|null, 3?: string|null}', $matches); +} + +function doUnknownFlags(string $s, int $flags): void { + if (preg_match('/(foo)(bar)(baz)/xyz', $s, $matches, $flags)) { + assertType('array}|string|null>', $matches); + } + assertType('array}|string|null>', $matches); +} + +function doNonAutoCapturingModifier(string $s): void { + if (preg_match('/(?n)(\d+)/', $s, $matches)) { + // could be assertType('array{string}', $matches); + assertType('array', $matches); + } + assertType('array', $matches); +} + +function doMultipleAlternativeCaptureGroupsWithSameNameWithModifier(string $s): void { + if (preg_match('/(?J)(?[a-z]+)|(?[0-9]+)/', $s, $matches)) { + // could be assertType('array{0: string, Foo: string, 1: string}', $matches); + assertType('array', $matches); + } + assertType('array', $matches); +} + +function doMultipleConsecutiveCaptureGroupsWithSameNameWithModifier(string $s): void { + if (preg_match('/(?J)(?[a-z]+)|(?[0-9]+)/', $s, $matches)) { + // could be assertType('array{0: string, Foo: string, 1: string}', $matches); + assertType('array', $matches); + } + assertType('array', $matches); +} + +// 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{}|array{string, string}', $matches); + + if (preg_match('/\w-(\d+)-(\w)/', $s, $matches)) { + assertType('array{string, string, string}', $matches); + } + assertType('array{}|array{string, string, string}', $matches); +} + +// https://github.com/phpstan/phpstan/issues/10855#issuecomment-2044323638 +function testHoaUnsupportedRegexSyntax(string $s): void { + if (preg_match('#\QPHPDoc type array of property App\Log::$fillable is not covariant with PHPDoc type array of overridden property Illuminate\Database\E\\\\\QEloquent\Model::$fillable.\E#', $s, $matches)) { + assertType('array{string}', $matches); + } + assertType('array{}|array{string}', $matches); +} + +function testPregMatchSimpleCondition(string $value): void { + if (preg_match('/%env\((.*)\:.*\)%/U', $value, $matches)) { + assertType('array{string, string}', $matches); + } +} + + +function testPregMatchIdenticalToOne(string $value): void { + if (preg_match('/%env\((.*)\:.*\)%/U', $value, $matches) === 1) { + assertType('array{string, string}', $matches); + } +} + +function testPregMatchIdenticalToOneFalseyContext(string $value): void { + if (!(preg_match('/%env\((.*)\:.*\)%/U', $value, $matches) !== 1)) { + assertType('array{string, string}', $matches); + } +} + +function testPregMatchIdenticalToOneInverted(string $value): void { + if (1 === preg_match('/%env\((.*)\:.*\)%/U', $value, $matches)) { + assertType('array{string, string}', $matches); + } +} + +function testPregMatchIdenticalToOneFalseyContextInverted(string $value): void { + if (!(1 !== preg_match('/%env\((.*)\:.*\)%/U', $value, $matches))) { + assertType('array{string, string}', $matches); + } +} diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php new file mode 100644 index 0000000000..d3f0421457 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php80.php @@ -0,0 +1,13 @@ += 8.0 + +namespace PregMatchShapesPhp82; + +use function PHPStan\Testing\assertType; + +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{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); +} diff --git a/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php new file mode 100644 index 0000000000..86cf0b0cdf --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/preg_match_shapes_php82.php @@ -0,0 +1,20 @@ += 8.2 + +namespace PregMatchShapesPhp82; + +use function PHPStan\Testing\assertType; + +// n modifier captures only named groups +// 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}', $matches); + } + assertType('array{}|array{string}', $matches); + + if (preg_match('/(\d+)(?P\d+)/n', $s, $matches)) { + // could be assertType('array{0: string, num: string, 1: string}', $matches); + assertType('array', $matches); + } + assertType('array', $matches); +} diff --git a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php index b8b0777ca2..ee616efbc5 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanOrConstantConditionRuleTest.php @@ -435,7 +435,16 @@ public function testBug6551(): void { $this->treatPhpDocTypesAsCertain = true; $this->reportAlwaysTrueInLastCondition = true; - $this->analyse([__DIR__ . '/data/bug-6551.php'], []); + $this->analyse([__DIR__ . '/data/bug-6551.php'], [ + [ + 'Result of || is always true.', + 49, + ], + [ + 'Result of || is always true.', + 61, + ], + ]); } public function testBug4004(): void diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index f20ba6c116..88d9315d84 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1094,4 +1094,11 @@ public function testBug10502(): void ]); } + public function testAlwaysTruePregMatch(): void + { + $this->checkAlwaysTrueCheckTypeFunctionCall = true; + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/always-true-preg-match.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/always-true-preg-match.php b/tests/PHPStan/Rules/Comparison/data/always-true-preg-match.php new file mode 100644 index 0000000000..160f21791a --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/always-true-preg-match.php @@ -0,0 +1,23 @@ +\S+::\S+)/', $test, $matches)) { + $test = $matches['name']; + } + + return $test; + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-6551.php b/tests/PHPStan/Rules/Comparison/data/bug-6551.php index 3b3e9574a6..561fbc9cfd 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-6551.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-6551.php @@ -46,7 +46,7 @@ function (): void { foreach ($data as $key => $value) { $match = []; - assertType('bool', preg_match('/^c(\d+)$/', $key, $match) || empty($match)); + assertType('true', preg_match('/^c(\d+)$/', $key, $match) || empty($match)); } }; @@ -58,6 +58,6 @@ function (): void { ]; foreach ($data as $key => $value) { - assertType('bool', preg_match('/^c(\d+)$/', $key, $match) || empty($match)); + assertType('true', preg_match('/^c(\d+)$/', $key, $match) || empty($match)); } };