Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Validate array function callback #555

Merged
2 changes: 2 additions & 0 deletions conf/bleedingEdge.neon
Expand Up @@ -21,3 +21,5 @@ parameters:
apiRules: true
deepInspectTypes: true
neverInGenericReturnType: true
stubFiles:
- ../stubs/arrayFunctions.stub
4 changes: 2 additions & 2 deletions src/Analyser/MutatingScope.php
Expand Up @@ -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);
Expand Down
36 changes: 34 additions & 2 deletions src/PhpDoc/StubPhpDocProvider.php
Expand Up @@ -52,6 +52,9 @@ class StubPhpDocProvider
/** @var array<string, array<string, array<string>>> */
private array $knownMethodsParameterNames = [];

/** @var array<string, array<string>> */
private array $knownFunctionParameterNames = [];

/**
* @param \PHPStan\Parser\Parser $parser
* @param string[] $stubFiles
Expand Down Expand Up @@ -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<int, string> $positionalParameterNames
* @return ResolvedPhpDocBlock|null
* @throws \PHPStan\ShouldNotHappenException
*/
public function findFunctionPhpDoc(string $functionName, array $positionalParameterNames): ?ResolvedPhpDocBlock
{
if (!$this->isKnownFunction($functionName)) {
return null;
Expand All @@ -176,14 +185,29 @@ 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,
$functionName,
$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];
}

Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 3 additions & 1 deletion src/Reflection/BetterReflection/BetterReflectionProvider.php
Expand Up @@ -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();
Expand Down
4 changes: 3 additions & 1 deletion src/Reflection/Runtime/RuntimeReflectionProvider.php
Expand Up @@ -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();
Expand Down
49 changes: 43 additions & 6 deletions src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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;
BackEndTea marked this conversation as resolved.
Show resolved Hide resolved
if ($phpDocParam !== null) {
$phpDocType = $phpDocParam->getType();
}
}
if (
$parameterSignature->getName() === 'values'
&& (
Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -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();
}

}
2 changes: 1 addition & 1 deletion src/Type/CallableType.php
Expand Up @@ -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()
Expand Down
67 changes: 67 additions & 0 deletions stubs/arrayFunctions.stub
@@ -0,0 +1,67 @@
<?php

/**
* @template TIn of mixed
* @template TReturn of mixed
*
* @param array<TIn> $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<TKey, TValue> $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<T> $one
* @param callable(T, T): int $two
*/
function uasort(
array &$one,
callable $two
): bool {}

/**
* @template T of mixed
*
* @param array<T> $one
* @param callable(T, T): int $two
*/
function usort(
array &$one,
callable $two
): bool {}

/**
* @template T
*
* @param array<T, mixed> $one
* @param callable(T, T): int $two
*/
function uksort(
array &$one,
callable $two
): bool {}
84 changes: 84 additions & 0 deletions tests/PHPStan/Rules/Functions/CallToFunctionParametersRuleTest.php
Expand Up @@ -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<CallToFunctionParametersRule>
Expand Down Expand Up @@ -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.',
BackEndTea marked this conversation as resolved.
Show resolved Hide resolved
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,
],
]);
}

}