diff --git a/phpstan-safe-rule.neon b/phpstan-safe-rule.neon index 22b037a..5573fe9 100644 --- a/phpstan-safe-rule.neon +++ b/phpstan-safe-rule.neon @@ -15,3 +15,7 @@ services: class: TheCodingMachine\Safe\PHPStan\Type\Php\PregMatchParameterOutTypeExtension tags: - phpstan.functionParameterOutTypeExtension + - + class: TheCodingMachine\Safe\PHPStan\Type\Php\PregMatchTypeSpecifyingExtension + tags: + - phpstan.typeSpecifier.functionTypeSpecifyingExtension diff --git a/src/Type/Php/PregMatchTypeSpecifyingExtension.php b/src/Type/Php/PregMatchTypeSpecifyingExtension.php new file mode 100644 index 0000000..8678918 --- /dev/null +++ b/src/Type/Php/PregMatchTypeSpecifyingExtension.php @@ -0,0 +1,85 @@ +typeSpecifier = $typeSpecifier; + } + + public function isFunctionSupported(FunctionReflection $functionReflection, FuncCall $node, TypeSpecifierContext $context): bool + { + return in_array(strtolower($functionReflection->getName()), ['safe\preg_match', 'safe\preg_match_all'], 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(); + } + + $flagsType = null; + if ($flagsArg !== null) { + $flagsType = $scope->getType($flagsArg->value); + } + + if ($functionReflection->getName() === 'Safe\preg_match') { + $matchedType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); + } else { + $matchedType = $this->regexShapeMatcher->matchAllExpr($patternArg->value, $flagsType, TrinaryLogic::createFromBoolean($context->true()), $scope); + } + if ($matchedType === null) { + return new SpecifiedTypes(); + } + + $overwrite = false; + if ($context->false()) { + $overwrite = true; + $context = $context->negate(); + } + + $types = $this->typeSpecifier->create( + $matchesArg->value, + $matchedType, + $context, + $scope, + )->setRootExpr($node); + if ($overwrite) { + $types = $types->setAlwaysOverwriteTypes(); + } + + return $types; + } + +} diff --git a/tests/Type/Php/data/preg.php b/tests/Type/Php/data/preg.php index 6e2819c..40af91d 100644 --- a/tests/Type/Php/data/preg.php +++ b/tests/Type/Php/data/preg.php @@ -5,6 +5,8 @@ // Checking that preg_match and Safe\preg_match are equivalent $pattern = '/H(.)ll(o) (World)?/'; $string = 'Hello World'; + +// when return value isn't checked, we may-or-may-not have matches $type = "array{0?: string, 1?: non-empty-string, 2?: 'o', 3?: 'World'}"; // @phpstan-ignore-next-line - use of unsafe is intentional @@ -13,3 +15,17 @@ \Safe\preg_match($pattern, $string, $matches); \PHPStan\Testing\assertType($type, $matches); + + +// when the return value is checked, we should have matches, +// unless the match-group itself is optional +$type = "array{0: string, 1: non-empty-string, 2: 'o', 3?: 'World'}"; + +// @phpstan-ignore-next-line - use of unsafe is intentional +if(\preg_match($pattern, $string, $matches)) { + \PHPStan\Testing\assertType($type, $matches); +} + +if(\Safe\preg_match($pattern, $string, $matches)) { + \PHPStan\Testing\assertType($type, $matches); +}