From 8fe28faef760fc3b655725a7ea9621debc27595b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Sat, 3 Aug 2024 08:57:11 +0200 Subject: [PATCH] Bleeding edge - Precise array shape for `preg_replace_callback()` `$matches` --- conf/config.neon | 5 ++ ...regReplaceCallbackClosureTypeExtension.php | 60 +++++++++++++++++++ .../preg_replace_callback_shapes-php72.php | 29 +++++++++ .../nsrt/preg_replace_callback_shapes.php | 47 +++++++++++++++ 4 files changed, 141 insertions(+) create mode 100644 src/Type/Php/PregReplaceCallbackClosureTypeExtension.php create mode 100644 tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes-php72.php create mode 100644 tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php diff --git a/conf/config.neon b/conf/config.neon index f649636e98..20cdfde763 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -295,6 +295,8 @@ conditionalTags: phpstan.typeSpecifier.functionTypeSpecifyingExtension: %featureToggles.narrowPregMatches% PHPStan\Type\Php\PregMatchParameterOutTypeExtension: phpstan.functionParameterOutTypeExtension: %featureToggles.narrowPregMatches% + PHPStan\Type\Php\PregReplaceCallbackClosureTypeExtension: + phpstan.functionParameterClosureTypeExtension: %featureToggles.narrowPregMatches% services: - @@ -1497,6 +1499,9 @@ services: - class: PHPStan\Type\Php\PregMatchParameterOutTypeExtension + - + class: PHPStan\Type\Php\PregReplaceCallbackClosureTypeExtension + - class: PHPStan\Type\Php\RegexArrayShapeMatcher diff --git a/src/Type/Php/PregReplaceCallbackClosureTypeExtension.php b/src/Type/Php/PregReplaceCallbackClosureTypeExtension.php new file mode 100644 index 0000000000..a7ea4dc133 --- /dev/null +++ b/src/Type/Php/PregReplaceCallbackClosureTypeExtension.php @@ -0,0 +1,60 @@ +getName() === 'preg_replace_callback' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + $args = $functionCall->getArgs(); + $patternArg = $args[0] ?? null; + $flagsArg = $args[5] ?? null; + + if ( + $patternArg === null + ) { + return null; + } + + $flagsType = null; + if ($flagsArg !== null) { + $flagsType = $scope->getType($flagsArg->value); + } + + $matchesType = $this->regexShapeMatcher->matchExpr($patternArg->value, $flagsType, TrinaryLogic::createYes(), $scope); + if ($matchesType === null) { + return null; + } + + return new ClosureType( + [ + new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $matchesType, $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()), + ], + new StringType(), + ); + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes-php72.php b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes-php72.php new file mode 100644 index 0000000000..2a3f437a81 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes-php72.php @@ -0,0 +1,29 @@ +', $matches); + return ''; + }, + $s + ); +}; + +function (string $s): void { + preg_replace_callback( + '|

(\s*)\w|', + function ($matches) { + assertType('array{string, string}', $matches); + return ''; + }, + $s + ); +}; + +// The flags parameter was added in PHP 7.4 diff --git a/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php new file mode 100644 index 0000000000..574be40769 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/preg_replace_callback_shapes.php @@ -0,0 +1,47 @@ += 7.4 + +namespace PregReplaceCallbackMatchShapes; + +use function PHPStan\Testing\assertType; + +function (string $s): void { + preg_replace_callback( + '/(foo)?(bar)?(baz)?/', + function ($matches) { + assertType('array{string, non-empty-string|null, non-empty-string|null, non-empty-string|null}', $matches); + return ''; + }, + $s, + -1, + $count, + PREG_UNMATCHED_AS_NULL + ); +}; + +function (string $s): void { + preg_replace_callback( + '/(foo)?(bar)?(baz)?/', + function ($matches) { + assertType('array{0: array{string, int<0, max>}, 1?: array{non-empty-string, int<0, max>}, 2?: array{non-empty-string, int<0, max>}, 3?: array{non-empty-string, int<0, max>}}', $matches); + return ''; + }, + $s, + -1, + $count, + PREG_OFFSET_CAPTURE + ); +}; + +function (string $s): void { + preg_replace_callback( + '/(foo)?(bar)?(baz)?/', + function ($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); + return ''; + }, + $s, + -1, + $count, + PREG_OFFSET_CAPTURE|PREG_UNMATCHED_AS_NULL + ); +};