diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index a8d0d796c7..a0cdb78baa 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -57,6 +57,7 @@ use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\InitializerExprTypeResolver; use PHPStan\Reflection\MethodReflection; +use PHPStan\Reflection\Native\NativeParameterReflection; use PHPStan\Reflection\ParameterReflection; use PHPStan\Reflection\Php\PhpFunctionFromParserNodeReflection; use PHPStan\Reflection\Php\PhpMethodFromParserNodeReflection; @@ -1398,6 +1399,56 @@ public function pushInFunctionCall($reflection, ?ParameterReflection $parameter, return $functionScope; } + /** + * Overrides the type of the parameter on top of the in-function-call stack. + * + * Used when a FunctionParameterClosureTypeExtension changes the type of a + * closure/callable parameter, so that the closure's return type inferred via + * ClosureTypeResolver (which reads the parameter type off this stack) matches + * the parameter type the closure body is actually analysed with. + */ + public function replaceInFunctionCallStackParameterType(Type $type): self + { + if (count($this->inFunctionCallsStack) === 0) { + return $this; + } + + $stack = $this->inFunctionCallsStack; + $lastIndex = count($stack) - 1; + [$reflection, $parameter] = $stack[$lastIndex]; + if ($parameter === null) { + return $this; + } + + $stack[$lastIndex] = [$reflection, new NativeParameterReflection( + $parameter->getName(), + $parameter->isOptional(), + $type, + $parameter->passedByReference(), + $parameter->isVariadic(), + $parameter->getDefaultValue(), + )]; + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $stack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + public function popInFunctionCall(): self { $stack = $this->inFunctionCallsStack; diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 07704c9fe7..ffac938a06 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3645,6 +3645,7 @@ public function processArgs( if ($overwritingParameterType !== null) { $parameterType = $overwritingParameterType; + $scopeToPass = $scopeToPass->replaceInFunctionCallStackParameterType($overwritingParameterType); } } @@ -3711,6 +3712,7 @@ public function processArgs( if ($overwritingParameterType !== null) { $parameterType = $overwritingParameterType; + $scopeToPass = $scopeToPass->replaceInFunctionCallStackParameterType($overwritingParameterType); } } diff --git a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php index 2309360031..4ab72a3893 100644 --- a/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ArrowFunctionReturnTypeRuleTest.php @@ -77,4 +77,9 @@ public function testBugFunctionMethodConstants(): void $this->analyse([__DIR__ . '/data/bug-anonymous-function-method-constant.php'], []); } + public function testBug14914(): void + { + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/ClosureReturnTypeParameterClosureExtensionRuleTest.php b/tests/PHPStan/Rules/Functions/ClosureReturnTypeParameterClosureExtensionRuleTest.php new file mode 100644 index 0000000000..a18cf7e223 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/ClosureReturnTypeParameterClosureExtensionRuleTest.php @@ -0,0 +1,44 @@ + + */ +class ClosureReturnTypeParameterClosureExtensionRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ClosureReturnTypeRule(new FunctionReturnTypeCheck( + new RuleLevelHelper( + self::createReflectionProvider(), + checkNullables: true, + checkThisOnly: false, + checkUnionTypes: true, + checkExplicitMixed: false, + checkImplicitMixed: false, + checkBenevolentUnionTypes: false, + discoveringSymbolsTip: true, + ), + )); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/closure-return-type-parameter-closure-extension.php'], []); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/data/closure-return-type-parameter-closure-extension.neon', + ]; + } + +} diff --git a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php index c363c194d3..3a9832d593 100644 --- a/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Functions/ClosureReturnTypeRuleTest.php @@ -149,4 +149,9 @@ public function testBug13964(): void $this->analyse([__DIR__ . '/data/bug-13964.php'], []); } + public function testBug14914(): void + { + $this->analyse([__DIR__ . '/data/bug-14914.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/bug-14914.php b/tests/PHPStan/Rules/Functions/data/bug-14914.php new file mode 100644 index 0000000000..7450dcf861 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/bug-14914.php @@ -0,0 +1,30 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug14914; + +function doFoo(): void +{ + preg_replace_callback( + '/a|(?b)/', + function (array $match): string { + if ($match['b'] !== null) { + return 'aa'; + } + return 'possible?'; + }, + 'abcd', + flags: PREG_UNMATCHED_AS_NULL, + ); +} + +function doBar(): void +{ + preg_replace_callback( + '/a|(?b)/', + fn (array $match) => $match['b'] !== null ? 'aa' : 'possible?', + 'abcd', + flags: PREG_UNMATCHED_AS_NULL, + ); +} diff --git a/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.neon b/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.neon new file mode 100644 index 0000000000..4ba3971320 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.neon @@ -0,0 +1,15 @@ +services: + - + class: ClosureReturnTypeParameterClosureExtension\FunctionParameterClosureTypeExtension + tags: + - phpstan.functionParameterClosureTypeExtension + + - + class: ClosureReturnTypeParameterClosureExtension\MethodParameterClosureTypeExtension + tags: + - phpstan.methodParameterClosureTypeExtension + + - + class: ClosureReturnTypeParameterClosureExtension\StaticMethodParameterClosureTypeExtension + tags: + - phpstan.staticMethodParameterClosureTypeExtension diff --git a/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.php b/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.php new file mode 100644 index 0000000000..b91be447f4 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/closure-return-type-parameter-closure-extension.php @@ -0,0 +1,130 @@ +setOffsetValueType(new ConstantStringType('k'), TypeCombinator::union(new StringType(), new NullType())); + + return new ClosureType( + [ + new NativeParameterReflection($parameter->getName(), $parameter->isOptional(), $builder->getArray(), $parameter->passedByReference(), $parameter->isVariadic(), $parameter->getDefaultValue()), + ], + new StringType(), + ); +} + +class FunctionParameterClosureTypeExtension implements \PHPStan\Type\FunctionParameterClosureTypeExtension +{ + + public function isFunctionSupported(FunctionReflection $functionReflection, ParameterReflection $parameter): bool + { + return $functionReflection->getName() === 'ClosureReturnTypeParameterClosureExtension\functionWithClosure' && $parameter->getName() === 'callback'; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return nullableOffsetClosureType($parameter); + } + +} + +class MethodParameterClosureTypeExtension implements \PHPStan\Type\MethodParameterClosureTypeExtension +{ + + public function isMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getDeclaringClass()->getName() === Foo::class && $methodReflection->getName() === 'methodWithClosure' && $parameter->getName() === 'callback'; + } + + public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return nullableOffsetClosureType($parameter); + } + +} + +class StaticMethodParameterClosureTypeExtension implements \PHPStan\Type\StaticMethodParameterClosureTypeExtension +{ + + public function isStaticMethodSupported(MethodReflection $methodReflection, ParameterReflection $parameter): bool + { + return $methodReflection->getDeclaringClass()->getName() === Foo::class && $methodReflection->getName() === 'staticMethodWithClosure' && $parameter->getName() === 'callback'; + } + + public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, ParameterReflection $parameter, Scope $scope): ?Type + { + return nullableOffsetClosureType($parameter); + } + +} + +class Foo +{ + + /** @param Closure(array{k: string}): string $callback */ + public function methodWithClosure(Closure $callback): void + { + } + + /** @param Closure(array{k: string}): string $callback */ + public static function staticMethodWithClosure(Closure $callback): void + { + } + +} + +/** @param Closure(array{k: string}): string $callback */ +function functionWithClosure(Closure $callback): void +{ +} + +function test(Foo $foo): void +{ + functionWithClosure(function (array $x): string { + if ($x['k'] !== null) { + return 'a'; + } + return 'b'; + }); + + $foo->methodWithClosure(function (array $x): string { + if ($x['k'] !== null) { + return 'a'; + } + return 'b'; + }); + + Foo::staticMethodWithClosure(function (array $x): string { + if ($x['k'] !== null) { + return 'a'; + } + return 'b'; + }); + + functionWithClosure(fn (array $x): string => $x['k'] !== null ? 'a' : 'b'); +}