diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index cba36f7fc1..ea66c26407 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -21,3 +21,5 @@ parameters: apiRules: true deepInspectTypes: true neverInGenericReturnType: true + stubFiles: + - ../stubs/arrayFunctions.stub diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1aff143607..992157446a 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -3234,8 +3234,8 @@ public function enterForeachKey(Expr $iteratee, string $keyName): self */ public function enterCatch(array $classes, ?string $variableName): self { - $type = TypeCombinator::union(...array_map(static function (string $class): ObjectType { - return new ObjectType($class); + $type = TypeCombinator::union(...array_map(static function (\PhpParser\Node\Name $class): ObjectType { + return new ObjectType((string) $class); }, $classes)); return $this->enterCatchType($type, $variableName); diff --git a/src/PhpDoc/StubPhpDocProvider.php b/src/PhpDoc/StubPhpDocProvider.php index e11e0bb234..75ca491d10 100644 --- a/src/PhpDoc/StubPhpDocProvider.php +++ b/src/PhpDoc/StubPhpDocProvider.php @@ -52,6 +52,9 @@ class StubPhpDocProvider /** @var array>> */ private array $knownMethodsParameterNames = []; + /** @var array> */ + private array $knownFunctionParameterNames = []; + /** * @param \PHPStan\Parser\Parser $parser * @param string[] $stubFiles @@ -164,7 +167,13 @@ public function findMethodPhpDoc(string $className, string $methodName, array $p return null; } - public function findFunctionPhpDoc(string $functionName): ?ResolvedPhpDocBlock + /** + * @param string $functionName + * @param array $positionalParameterNames + * @return ResolvedPhpDocBlock|null + * @throws \PHPStan\ShouldNotHappenException + */ + public function findFunctionPhpDoc(string $functionName, array $positionalParameterNames): ?ResolvedPhpDocBlock { if (!$this->isKnownFunction($functionName)) { return null; @@ -176,7 +185,7 @@ public function findFunctionPhpDoc(string $functionName): ?ResolvedPhpDocBlock if (array_key_exists($functionName, $this->knownFunctionsDocComments)) { [$file, $docComment] = $this->knownFunctionsDocComments[$functionName]; - $this->functionMap[$functionName] = $this->fileTypeMapper->getResolvedPhpDoc( + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( $file, null, null, @@ -184,6 +193,21 @@ public function findFunctionPhpDoc(string $functionName): ?ResolvedPhpDocBlock $docComment ); + if (!isset($this->knownFunctionParameterNames[$functionName])) { + throw new \PHPStan\ShouldNotHappenException(); + } + + $functionParameterNames = $this->knownFunctionParameterNames[$functionName]; + $parameterNameMapping = []; + foreach ($positionalParameterNames as $i => $parameterName) { + if (!array_key_exists($i, $functionParameterNames)) { + continue; + } + $parameterNameMapping[$functionParameterNames[$i]] = $parameterName; + } + + $this->functionMap[$functionName] = $resolvedPhpDoc->changeParameterNamesByMapping($parameterNameMapping); + return $this->functionMap[$functionName]; } @@ -252,6 +276,14 @@ private function initializeKnownElementNode(string $stubFile, Node $node): void $this->functionMap[$functionName] = null; return; } + $this->knownFunctionParameterNames[$functionName] = array_map(static function (Node\Param $param): string { + if (!$param->var instanceof Variable || !is_string($param->var->name)) { + throw new \PHPStan\ShouldNotHappenException(); + } + + return $param->var->name; + }, $node->getParams()); + $this->knownFunctionsDocComments[$functionName] = [$stubFile, $docComment->getText()]; return; } diff --git a/src/Reflection/BetterReflection/BetterReflectionProvider.php b/src/Reflection/BetterReflection/BetterReflectionProvider.php index 349ec76486..35cf41a275 100644 --- a/src/Reflection/BetterReflection/BetterReflectionProvider.php +++ b/src/Reflection/BetterReflection/BetterReflectionProvider.php @@ -281,7 +281,9 @@ private function getCustomFunction(string $functionName): \PHPStan\Reflection\Ph $isInternal = false; $isFinal = false; $isPure = null; - $resolvedPhpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($reflectionFunction->getName()); + $resolvedPhpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($reflectionFunction->getName(), array_map(static function (\ReflectionParameter $parameter): string { + return $parameter->getName(); + }, $reflectionFunction->getParameters())); if ($resolvedPhpDoc === null && $reflectionFunction->getFileName() !== false && $reflectionFunction->getDocComment() !== false) { $fileName = $reflectionFunction->getFileName(); $docComment = $reflectionFunction->getDocComment(); diff --git a/src/Reflection/Runtime/RuntimeReflectionProvider.php b/src/Reflection/Runtime/RuntimeReflectionProvider.php index 517f1eb93f..7696136a6f 100644 --- a/src/Reflection/Runtime/RuntimeReflectionProvider.php +++ b/src/Reflection/Runtime/RuntimeReflectionProvider.php @@ -265,7 +265,9 @@ private function getCustomFunction(\PhpParser\Node\Name $nameNode, ?Scope $scope $isInternal = false; $isFinal = false; $isPure = null; - $resolvedPhpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($reflectionFunction->getName()); + $resolvedPhpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($reflectionFunction->getName(), array_map(static function (\ReflectionParameter $parameter): string { + return $parameter->getName(); + }, $reflectionFunction->getParameters())); if ($resolvedPhpDoc === null && $reflectionFunction->getFileName() !== false && $reflectionFunction->getDocComment() !== false) { $fileName = $reflectionFunction->getFileName(); $docComment = $reflectionFunction->getDocComment(); diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index 7d0b57536b..2e466e1f8c 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -4,6 +4,8 @@ use PHPStan\BetterReflection\Identifier\Exception\InvalidIdentifierName; use PHPStan\BetterReflection\Reflector\FunctionReflector; +use PHPStan\PhpDoc\ResolvedPhpDocBlock; +use PHPStan\PhpDoc\StubPhpDocProvider; use PHPStan\Reflection\FunctionVariant; use PHPStan\Reflection\Native\NativeFunctionReflection; use PHPStan\Reflection\Native\NativeParameterReflection; @@ -17,6 +19,8 @@ use PHPStan\Type\NullType; use PHPStan\Type\StringAlwaysAcceptingObjectWithToStringType; use PHPStan\Type\StringType; +use PHPStan\Type\Type; +use PHPStan\Type\TypehintHelper; use PHPStan\Type\UnionType; class NativeFunctionReflectionProvider @@ -31,11 +35,14 @@ class NativeFunctionReflectionProvider private \PHPStan\Type\FileTypeMapper $fileTypeMapper; - public function __construct(SignatureMapProvider $signatureMapProvider, FunctionReflector $functionReflector, FileTypeMapper $fileTypeMapper) + private StubPhpDocProvider $stubPhpDocProvider; + + public function __construct(SignatureMapProvider $signatureMapProvider, FunctionReflector $functionReflector, FileTypeMapper $fileTypeMapper, StubPhpDocProvider $stubPhpDocProvider) { $this->signatureMapProvider = $signatureMapProvider; $this->functionReflector = $functionReflector; $this->fileTypeMapper = $fileTypeMapper; + $this->stubPhpDocProvider = $stubPhpDocProvider; } public function findFunctionReflection(string $functionName): ?NativeFunctionReflection @@ -48,17 +55,30 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef if (!$this->signatureMapProvider->hasFunctionSignature($lowerCasedFunctionName)) { return null; } + $reflectionFunction = $this->signatureMapProvider->getFunctionSignature($lowerCasedFunctionName, null); + + $phpDoc = $this->stubPhpDocProvider->findFunctionPhpDoc($lowerCasedFunctionName, array_map(static function (ParameterSignature $parameter): string { + return $parameter->getName(); + }, $reflectionFunction->getParameters())); $variants = []; $i = 0; while ($this->signatureMapProvider->hasFunctionSignature($lowerCasedFunctionName, $i)) { $functionSignature = $this->signatureMapProvider->getFunctionSignature($lowerCasedFunctionName, null, $i); - $returnType = $functionSignature->getReturnType(); $variants[] = new FunctionVariant( TemplateTypeMap::createEmpty(), null, - array_map(static function (ParameterSignature $parameterSignature) use ($lowerCasedFunctionName): NativeParameterReflection { + array_map(static function (ParameterSignature $parameterSignature) use ($lowerCasedFunctionName, $phpDoc): NativeParameterReflection { $type = $parameterSignature->getType(); + $defaultValue = null; + + $phpDocType = null; + if ($phpDoc !== null) { + $phpDocParam = $phpDoc->getParamTags()[$parameterSignature->getName()] ?? null; + if ($phpDocParam !== null) { + $phpDocType = $phpDocParam->getType(); + } + } if ( $parameterSignature->getName() === 'values' && ( @@ -94,17 +114,24 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef ); } + if ( + $lowerCasedFunctionName === 'array_reduce' + && $parameterSignature->getName() === 'initial' + ) { + $defaultValue = new NullType(); + } + return new NativeParameterReflection( $parameterSignature->getName(), $parameterSignature->isOptional(), - $type, + TypehintHelper::decideType($type, $phpDocType), $parameterSignature->passedByReference(), $parameterSignature->isVariadic(), - null + $defaultValue ); }, $functionSignature->getParameters()), $functionSignature->isVariadic(), - $returnType + TypehintHelper::decideType($functionSignature->getReturnType(), $phpDoc !== null ? $this->getReturnTypeFromPhpDoc($phpDoc) : null) ); $i++; @@ -145,4 +172,14 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef return $functionReflection; } + private function getReturnTypeFromPhpDoc(ResolvedPhpDocBlock $phpDoc): ?Type + { + $returnTag = $phpDoc->getReturnTag(); + if ($returnTag === null) { + return null; + } + + return $returnTag->getType(); + } + } diff --git a/src/Type/CallableType.php b/src/Type/CallableType.php index 4a8176e57e..27c89ce3f6 100644 --- a/src/Type/CallableType.php +++ b/src/Type/CallableType.php @@ -139,7 +139,7 @@ function () use ($level): string { return sprintf( 'callable(%s): %s', implode(', ', array_map( - static function (NativeParameterReflection $param) use ($level): string { + static function (ParameterReflection $param) use ($level): string { return sprintf('%s%s', $param->isVariadic() ? '...' : '', $param->getType()->describe($level)); }, $this->getParameters() diff --git a/stubs/arrayFunctions.stub b/stubs/arrayFunctions.stub new file mode 100644 index 0000000000..c8a9a62377 --- /dev/null +++ b/stubs/arrayFunctions.stub @@ -0,0 +1,67 @@ + $one + * @param callable(TReturn, TIn): TReturn $two + * @param TReturn $three + * + * @return TReturn + */ +function array_reduce( + array $one, + callable $two, + $three = null +) {} + +/** + * @template TKey of array-key + * @template TValue of mixed + * @template TUser of mixed + * + * @param array $one + * @param callable(int, TKey, TUser=): mixed $two + * @param TUser $three + * + * @return true + */ +function array_walk( + array &$one, + callable $two, + $three = null +): bool {} + +/** + * @template T of mixed + * + * @param array $one + * @param callable(T, T): int $two + */ +function uasort( + array &$one, + callable $two +): bool {} + +/** + * @template T of mixed + * + * @param array $one + * @param callable(T, T): int $two + */ +function usort( + array &$one, + callable $two +): bool {} + +/** + * @template T + * + * @param array $one + * @param callable(T, T): int $two + */ +function uksort( + array &$one, + callable $two +): bool {} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php index c59070a150..8efd1de3fe 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php @@ -7,6 +7,7 @@ use PHPStan\Rules\NullsafeCheck; use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper; use PHPStan\Rules\RuleLevelHelper; +use const PHP_VERSION_ID; /** * @extends \PHPStan\Testing\RuleTestCase @@ -554,4 +555,87 @@ public function testBugNumberFormatNamedArguments(): void $this->analyse([__DIR__ . '/data/number-format-named-arguments.php'], []); } + public function testArrayReduceCallback(): void + { + $this->analyse([__DIR__ . '/data/array_reduce.php'], [ + [ + 'Parameter #2 $callback of function array_reduce expects callable(string, int|string): string, Closure(string, string): string given.', + 5, + ], + [ + 'Parameter #2 $callback of function array_reduce expects callable(string|null, int): string|null, Closure(string, int): string given.', + 13, + ], + [ + 'Parameter #2 $callback of function array_reduce expects callable(string|null, int): string|null, Closure(string, int): string given.', + 22, + ], + ]); + } + + public function testArrayWalkCallback(): void + { + $this->analyse([__DIR__ . '/data/array_walk.php'], [ + [ + 'Parameter #2 $callback of function array_walk expects callable(int, string, *NEVER*): mixed, Closure(stdClass, float): \'\' given.', + 6, + ], + [ + 'Parameter #2 $callback of function array_walk expects callable(int, string, int|string): mixed, Closure(int, string, int): \'\' given.', + 14, + ], + ]); + } + + public function testUasortCallback(): void + { + $paramTwoName = PHP_VERSION_ID >= 80000 + ? 'callback' + : 'cmp_function'; + + $this->analyse([__DIR__ . '/data/uasort.php'], [ + [ + sprintf( + 'Parameter #2 $%s of function uasort expects callable(int|string, int|string): int, Closure(string, string): 1 given.', + $paramTwoName + ), + 7, + ], + ]); + } + + public function testUsortCallback(): void + { + $paramTwoName = PHP_VERSION_ID >= 80000 + ? 'callback' + : 'cmp_function'; + + $this->analyse([__DIR__ . '/data/usort.php'], [ + [ + sprintf( + 'Parameter #2 $%s of function usort expects callable(int|string, int|string): int, Closure(string, string): 1 given.', + $paramTwoName + ), + 7, + ], + ]); + } + + public function testUksortCallback(): void + { + $paramTwoName = PHP_VERSION_ID >= 80000 + ? 'callback' + : 'cmp_function'; + + $this->analyse([__DIR__ . '/data/uksort.php'], [ + [ + sprintf( + 'Parameter #2 $%s of function uksort expects callable(stdClass|string, stdClass|string): int, Closure(stdClass, stdClass): 1 given.', + $paramTwoName + ), + 7, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Functions/data/array_reduce.php b/tests/PHPStan/Rules/Functions/data/array_reduce.php new file mode 100644 index 0000000000..eb7588e9cc --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/array_reduce.php @@ -0,0 +1,25 @@ + 1, 'bar' => 2]; +array_walk( + $array, + function(stdClass $in, float $key): string { + return ''; + } +); + +$array = ['foo' => 1, 'bar' => 2]; +array_walk( + $array, + function(int $in, string $key, int $extra): string { + return ''; + }, + 'extra' +); diff --git a/tests/PHPStan/Rules/Functions/data/uasort.php b/tests/PHPStan/Rules/Functions/data/uasort.php new file mode 100644 index 0000000000..8b34c003f4 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/uasort.php @@ -0,0 +1,10 @@ + 1, 'two' => 2, 'three' => 3]; + +uksort( + $array, + function (stdClass $one, stdClass $two): int { + return 1; + } +); diff --git a/tests/PHPStan/Rules/Functions/data/usort.php b/tests/PHPStan/Rules/Functions/data/usort.php new file mode 100644 index 0000000000..ad33e36da1 --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/usort.php @@ -0,0 +1,10 @@ +