diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index db31ce7669..8b26dd18b9 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -60,6 +60,7 @@ use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\NewAssignedToPropertyVisitor; use PHPStan\Parser\Parser; +use PHPStan\Parser\PregReplaceCallbackArgVisitor; use PHPStan\Php\PhpVersion; use PHPStan\Php\PhpVersionFactory; use PHPStan\Php\PhpVersions; @@ -102,6 +103,7 @@ use PHPStan\Type\BooleanType; use PHPStan\Type\ClosureType; use PHPStan\Type\ConditionalTypeForParameter; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\Constant\ConstantFloatType; @@ -177,6 +179,8 @@ use const PHP_INT_MAX; use const PHP_INT_MIN; use const PHP_VERSION_ID; +use const PREG_OFFSET_CAPTURE; +use const PREG_UNMATCHED_AS_NULL; class MutatingScope implements Scope, NodeCallbackInvoker { @@ -5681,6 +5685,38 @@ private function getClosureType(Expr\Closure|Expr\ArrowFunction $node): ClosureT foreach ($immediatelyInvokedArgs as $immediatelyInvokedArg) { $callableParameters[] = new DummyParameter('item', $this->getType($immediatelyInvokedArg->value), optional: false, passedByReference: PassedByReference::createNo(), variadic: false, defaultValue: null); } + } elseif ($node->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME) instanceof Node\Expr) { + $pregFlagsExpr = $node->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME); + $flagsType = $this->getType($pregFlagsExpr); + if ($flagsType instanceof ConstantIntegerType) { + $flags = $flagsType->getValue(); + $offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0; + $unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0; + + $matchValueType = new StringType(); + if ($unmatchedAsNull) { + $matchValueType = TypeCombinator::addNull($matchValueType); + } + if ($offsetCapture) { + $matchValueType = new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [$matchValueType, IntegerRangeType::fromInterval(-1, null)], + [2], + isList: TrinaryLogic::createYes(), + ); + } + + $callableParameters = [ + new DummyParameter( + 'matches', + new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType), + false, + PassedByReference::createNo(), + false, + null, + ), + ]; + } } else { $inFunctionCallsStackCount = count($this->inFunctionCallsStack); if ($inFunctionCallsStackCount > 0) { diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 8a807ea745..d833b36978 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -133,6 +133,7 @@ use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\LineAttributesVisitor; use PHPStan\Parser\Parser; +use PHPStan\Parser\PregReplaceCallbackArgVisitor; use PHPStan\Parser\ReversePipeTransformerVisitor; use PHPStan\Php\PhpVersion; use PHPStan\PhpDoc\PhpDocInheritanceResolver; @@ -156,6 +157,7 @@ use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\ParametersAcceptor; use PHPStan\Reflection\ParametersAcceptorSelector; +use PHPStan\Reflection\PassedByReference; use PHPStan\Reflection\Php\PhpFunctionFromParserNodeReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; use PHPStan\Reflection\Php\PhpMethodReflection; @@ -229,6 +231,8 @@ use function trim; use function usort; use const PHP_VERSION_ID; +use const PREG_OFFSET_CAPTURE; +use const PREG_UNMATCHED_AS_NULL; use const SORT_NUMERIC; #[AutowiredService] @@ -5340,6 +5344,42 @@ public function createCallableParameters(Scope $scope, Expr $closureExpr, ?array } } + if ($callableParameters === null) { + $pregFlagsExpr = $closureExpr->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME); + if ($pregFlagsExpr instanceof Node\Expr) { + $flagsType = $scope->getType($pregFlagsExpr); + if ($flagsType instanceof ConstantIntegerType) { + $flags = $flagsType->getValue(); + $offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0; + $unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0; + + $matchValueType = new StringType(); + if ($unmatchedAsNull) { + $matchValueType = TypeCombinator::addNull($matchValueType); + } + if ($offsetCapture) { + $matchValueType = new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [$matchValueType, IntegerRangeType::fromInterval(-1, null)], + [2], + isList: TrinaryLogic::createYes(), + ); + } + + $callableParameters = [ + new NativeParameterReflection( + 'matches', + false, + new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType), + PassedByReference::createNo(), + false, + null, + ), + ]; + } + } + } + return $callableParameters; } diff --git a/src/Parser/PregReplaceCallbackArgVisitor.php b/src/Parser/PregReplaceCallbackArgVisitor.php new file mode 100644 index 0000000000..833159342f --- /dev/null +++ b/src/Parser/PregReplaceCallbackArgVisitor.php @@ -0,0 +1,53 @@ +name instanceof Node\Name || $node->isFirstClassCallable()) { + return null; + } + + $functionName = $node->name->toLowerString(); + + if ($functionName === 'preg_replace_callback') { + $args = $node->getArgs(); + if (isset($args[1]) && isset($args[5])) { + $args[1]->setAttribute(self::ATTRIBUTE_NAME, $args[5]->value); + } + } elseif ($functionName === 'preg_replace_callback_array') { + $args = $node->getArgs(); + if (!isset($args[0]) || !isset($args[4])) { + return null; + } + $args[0]->setAttribute(self::ATTRIBUTE_NAME, $args[4]->value); + + // Also set the attribute on closures/arrow functions inside the array values + $arrayArg = $args[0]->value; + if ($arrayArg instanceof Node\Expr\Array_) { + foreach ($arrayArg->items as $item) { + if (!($item->value instanceof Node\Expr\Closure) && !($item->value instanceof Node\Expr\ArrowFunction)) { + continue; + } + + $item->value->setAttribute(self::ATTRIBUTE_NAME, $args[4]->value); + } + } + } + + return null; + } + +} diff --git a/src/Reflection/ParametersAcceptorSelector.php b/src/Reflection/ParametersAcceptorSelector.php index 20317a1405..bf0ae55238 100644 --- a/src/Reflection/ParametersAcceptorSelector.php +++ b/src/Reflection/ParametersAcceptorSelector.php @@ -17,6 +17,7 @@ use PHPStan\Parser\CurlSetOptArgVisitor; use PHPStan\Parser\CurlSetOptArrayArgVisitor; use PHPStan\Parser\ImplodeArgVisitor; +use PHPStan\Parser\PregReplaceCallbackArgVisitor; use PHPStan\Reflection\Callables\CallableParametersAcceptor; use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\Php\DummyParameter; @@ -27,10 +28,12 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\BooleanType; use PHPStan\Type\CallableType; +use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Generic\TemplateTypeMap; use PHPStan\Type\Generic\TemplateTypeVarianceMap; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; @@ -59,6 +62,8 @@ use const ARRAY_FILTER_USE_KEY; use const CURLOPT_SHARE; use const CURLOPT_SSL_VERIFYHOST; +use const PREG_OFFSET_CAPTURE; +use const PREG_UNMATCHED_AS_NULL; /** * @api @@ -372,6 +377,82 @@ public static function selectFromArgs( } } + foreach ([1, 0] as $pregArgIndex) { + if (!isset($args[$pregArgIndex])) { + continue; + } + + $pregFlagsExpr = $args[$pregArgIndex]->getAttribute(PregReplaceCallbackArgVisitor::ATTRIBUTE_NAME); + if (!$pregFlagsExpr instanceof Node\Expr) { + continue; + } + + $flagsType = $scope->getType($pregFlagsExpr); + if (!$flagsType instanceof ConstantIntegerType) { + break; + } + + $flags = $flagsType->getValue(); + $offsetCapture = ($flags & PREG_OFFSET_CAPTURE) !== 0; + $unmatchedAsNull = ($flags & PREG_UNMATCHED_AS_NULL) !== 0; + + if (!$offsetCapture && !$unmatchedAsNull) { + break; + } + + $matchValueType = new StringType(); + if ($unmatchedAsNull) { + $matchValueType = TypeCombinator::addNull($matchValueType); + } + if ($offsetCapture) { + $matchValueType = new ConstantArrayType( + [new ConstantIntegerType(0), new ConstantIntegerType(1)], + [$matchValueType, IntegerRangeType::fromInterval(-1, null)], + [2], + isList: TrinaryLogic::createYes(), + ); + } + + $callbackParameter = new DummyParameter( + 'matches', + new ArrayType(new UnionType([new IntegerType(), new StringType()]), $matchValueType), + optional: false, + passedByReference: PassedByReference::createNo(), + variadic: false, + defaultValue: null, + ); + $callbackType = new CallableType([$callbackParameter], new StringType(), false); + + $acceptor = $parametersAcceptors[0]; + $parameters = $acceptor->getParameters(); + if (isset($parameters[$pregArgIndex])) { + $pregReplaceCallbackIsArray = $pregArgIndex === 0; + $newParamType = $pregReplaceCallbackIsArray + ? new ArrayType(new StringType(), $callbackType) + : $callbackType; + $parameters[$pregArgIndex] = new NativeParameterReflection( + $parameters[$pregArgIndex]->getName(), + $parameters[$pregArgIndex]->isOptional(), + $newParamType, + $parameters[$pregArgIndex]->passedByReference(), + $parameters[$pregArgIndex]->isVariadic(), + $parameters[$pregArgIndex]->getDefaultValue(), + ); + $parametersAcceptors = [ + new FunctionVariant( + $acceptor->getTemplateTypeMap(), + $acceptor->getResolvedTemplateTypeMap(), + $parameters, + $acceptor->isVariadic(), + $acceptor->getReturnType(), + $acceptor instanceof ExtendedParametersAcceptor ? $acceptor->getCallSiteVarianceMap() : TemplateTypeVarianceMap::createEmpty(), + ), + ]; + } + + break; + } + $closureBindToVar = $args[0]->getAttribute(ClosureBindToVarVisitor::ATTRIBUTE_NAME); if ( $closureBindToVar instanceof Node\Expr\Variable diff --git a/tests/PHPStan/Analyser/nsrt/bug-10396.php b/tests/PHPStan/Analyser/nsrt/bug-10396.php new file mode 100644 index 0000000000..a8e81efbdb --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-10396.php @@ -0,0 +1,79 @@ += 7.4 + +namespace Bug10396; + +use function PHPStan\Testing\assertType; + +// preg_replace_callback_array - without flags +function testCallbackArrayNoFlags(string $s): void { + preg_replace_callback_array( + [ + '/(foo)(bar)/' => function ($matches) { + assertType("mixed", $matches); // no flags, no attribute set + return ''; + }, + ], + $s + ); +} + +// preg_replace_callback_array with PREG_UNMATCHED_AS_NULL +function testCallbackArrayUnmatchedAsNull(string $s): void { + preg_replace_callback_array( + [ + '/(foo)?(bar)/' => function ($matches) { + assertType("array", $matches); + return ''; + }, + ], + $s, + -1, + $count, + PREG_UNMATCHED_AS_NULL + ); +} + +// preg_replace_callback_array with PREG_OFFSET_CAPTURE +function testCallbackArrayOffsetCapture(string $s): void { + preg_replace_callback_array( + [ + '/(foo)(bar)/' => function ($matches) { + assertType("array}>", $matches); + return ''; + }, + ], + $s, + -1, + $count, + PREG_OFFSET_CAPTURE + ); +} + +// preg_replace_callback_array with both flags +function testCallbackArrayBothFlags(string $s): void { + preg_replace_callback_array( + [ + '/(foo)?(bar)/' => function ($matches) { + assertType("array}>", $matches); + return ''; + }, + ], + $s, + -1, + $count, + PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL + ); +} + +// preg_replace_callback_array with arrow function +function testCallbackArrayArrowFunction(string $s): void { + preg_replace_callback_array( + [ + '/(foo)(bar)/' => fn ($matches) => assertType("array}>", $matches) ? '' : '', + ], + $s, + -1, + $count, + PREG_OFFSET_CAPTURE + ); +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index 9fa51e42e6..11fc8ecf20 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -742,6 +742,11 @@ public function testPregReplaceCallback(): void ]); } + public function testBug10396(): void + { + $this->analyse([__DIR__ . '/data/bug-10396.php'], []); + } + public function testMbEregReplaceCallback(): void { $this->analyse([__DIR__ . '/data/mb_ereg_replace_callback.php'], [ diff --git a/tests/PHPStan/Rules/Functions/data/bug-10396.php b/tests/PHPStan/Rules/Functions/data/bug-10396.php new file mode 100644 index 0000000000..b694c115fc --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-10396.php @@ -0,0 +1,64 @@ += 7.4 + +namespace Bug10396; + +/** + * @param callable(array): string $callback + */ +function acceptCallback(callable $callback): void {} + +// Reproduce the exact issue: user explicitly types callback for PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL +function testOffsetCaptureWithUnmatchedAsNull(string $s): ?string { + return preg_replace_callback( + '/(foo)/', + /** @param array $matches */ + function (array $matches): string { + return $matches[0][0] ?? ''; + }, + $s, + -1, + $count, + PREG_OFFSET_CAPTURE | PREG_UNMATCHED_AS_NULL + ); +} + +// PREG_OFFSET_CAPTURE only +function testOffsetCapture(string $s): ?string { + return preg_replace_callback( + '/(foo)(bar)/', + /** @param array $matches */ + function (array $matches): string { + return $matches[0][0]; + }, + $s, + -1, + $count, + PREG_OFFSET_CAPTURE + ); +} + +// PREG_UNMATCHED_AS_NULL only +function testUnmatchedAsNull(string $s): ?string { + return preg_replace_callback( + '/(foo)?(bar)/', + /** @param array $matches */ + function (array $matches): string { + return $matches[0] ?? ''; + }, + $s, + -1, + $count, + PREG_UNMATCHED_AS_NULL + ); +} + +// No flags - existing behavior should still work +function testNoFlags(string $s): ?string { + return preg_replace_callback( + '/(foo)(bar)/', + function (array $matches): string { + return $matches[0]; + }, + $s + ); +}