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

Support all array_filter flags for narrowing down types #941

Merged
merged 1 commit into from Jan 29, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
130 changes: 87 additions & 43 deletions src/Type/Php/ArrayFilterFunctionReturnTypeReturnTypeExtension.php
Expand Up @@ -2,8 +2,11 @@

namespace PHPStan\Type\Php;

use PhpParser\Node\Expr;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\ConstFetch;
use PhpParser\Node\Expr\Error;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Stmt\Return_;
Expand All @@ -26,6 +29,7 @@
use function array_map;
use function count;
use function is_string;
use function strtolower;

class ArrayFilterFunctionReturnTypeReturnTypeExtension implements DynamicFunctionReturnTypeExtension
{
Expand All @@ -41,57 +45,62 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
$callbackArg = $functionCall->getArgs()[1]->value ?? null;
$flagArg = $functionCall->getArgs()[2]->value ?? null;

if ($arrayArg !== null) {
$arrayArgType = $scope->getType($arrayArg);
$keyType = $arrayArgType->getIterableKeyType();
$itemType = $arrayArgType->getIterableValueType();
if ($arrayArg === null) {
return new ArrayType(new MixedType(), new MixedType());
}

if ($arrayArgType instanceof MixedType) {
return new BenevolentUnionType([
new ArrayType(new MixedType(), new MixedType()),
new NullType(),
]);
}
$arrayArgType = $scope->getType($arrayArg);
$keyType = $arrayArgType->getIterableKeyType();
$itemType = $arrayArgType->getIterableValueType();

if ($arrayArgType instanceof MixedType) {
return new BenevolentUnionType([
new ArrayType(new MixedType(), new MixedType()),
new NullType(),
]);
}

if ($callbackArg === null) {
return TypeCombinator::union(
...array_map([$this, 'removeFalsey'], TypeUtils::getArrays($arrayArgType)),
);
if ($callbackArg === null || ($callbackArg instanceof ConstFetch && strtolower($callbackArg->name->parts[0]) === 'null')) {
return TypeCombinator::union(
...array_map([$this, 'removeFalsey'], TypeUtils::getArrays($arrayArgType)),
);
}

if ($flagArg === null) {
if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) {
$statement = $callbackArg->stmts[0];
if ($statement instanceof Return_ && $statement->expr !== null) {
[$itemType, $keyType] = $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $itemType, null, $keyType, $statement->expr);
}
} elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) {
[$itemType, $keyType] = $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $itemType, null, $keyType, $callbackArg->expr);
}
}

if ($flagArg === null) {
$var = null;
$expr = null;
if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) {
$statement = $callbackArg->stmts[0];
if ($statement instanceof Return_ && $statement->expr !== null) {
$var = $callbackArg->params[0]->var;
$expr = $statement->expr;
}
} elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) {
$var = $callbackArg->params[0]->var;
$expr = $callbackArg->expr;
if ($flagArg instanceof ConstFetch && $flagArg->name->parts[0] === 'ARRAY_FILTER_USE_KEY') {
if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) {
$statement = $callbackArg->stmts[0];
if ($statement instanceof Return_ && $statement->expr !== null) {
[$itemType, $keyType] = $this->filterByTruthyValue($scope, null, $itemType, $callbackArg->params[0]->var, $keyType, $statement->expr);
}
if ($var !== null && $expr !== null) {
if (!$var instanceof Variable || !is_string($var->name)) {
throw new ShouldNotHappenException();
}
$itemVariableName = $var->name;
if (!$scope instanceof MutatingScope) {
throw new ShouldNotHappenException();
}
$scope = $scope->assignVariable($itemVariableName, $itemType);
$scope = $scope->filterByTruthyValue($expr);
$itemType = $scope->getVariableType($itemVariableName);
if ($itemType instanceof NeverType) {
return new ConstantArrayType([], []);
}
} elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) {
[$itemType, $keyType] = $this->filterByTruthyValue($scope, null, $itemType, $callbackArg->params[0]->var, $keyType, $callbackArg->expr);
}
}

if ($flagArg instanceof ConstFetch && $flagArg->name->parts[0] === 'ARRAY_FILTER_USE_BOTH') {
if ($callbackArg instanceof Closure && count($callbackArg->stmts) === 1 && count($callbackArg->params) > 0) {
$statement = $callbackArg->stmts[0];
if ($statement instanceof Return_ && $statement->expr !== null) {
[$itemType, $keyType] = $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $itemType, $callbackArg->params[1]->var ?? null, $keyType, $statement->expr);
}
} elseif ($callbackArg instanceof ArrowFunction && count($callbackArg->params) > 0) {
[$itemType, $keyType] = $this->filterByTruthyValue($scope, $callbackArg->params[0]->var, $itemType, $callbackArg->params[1]->var ?? null, $keyType, $callbackArg->expr);
}
}

} else {
$keyType = new MixedType();
$itemType = new MixedType();
if ($itemType instanceof NeverType || $keyType instanceof NeverType) {
return new ConstantArrayType([], []);
}

return new ArrayType($keyType, $itemType);
Expand Down Expand Up @@ -132,4 +141,39 @@ public function removeFalsey(Type $type): Type
return new ArrayType($keyType, $valueType);
}

/**
* @return array{Type, Type}
*/
private function filterByTruthyValue(Scope $scope, Error|Variable|null $itemVar, Type $itemType, Error|Variable|null $keyVar, Type $keyType, Expr $expr): array
{
if (!$scope instanceof MutatingScope) {
throw new ShouldNotHappenException();
}

$itemVarName = null;
if ($itemVar !== null) {
if (!$itemVar instanceof Variable || !is_string($itemVar->name)) {
throw new ShouldNotHappenException();
}
$itemVarName = $itemVar->name;
$scope = $scope->assignVariable($itemVarName, $itemType);
}

$keyVarName = null;
if ($keyVar !== null) {
if (!$keyVar instanceof Variable || !is_string($keyVar->name)) {
throw new ShouldNotHappenException();
}
$keyVarName = $keyVar->name;
$scope = $scope->assignVariable($keyVarName, $keyType);
}

$scope = $scope->filterByTruthyValue($expr);

return [
$itemVarName !== null ? $scope->getVariableType($itemVarName) : $itemType,
$keyVarName !== null ? $scope->getVariableType($keyVarName) : $keyType,
];
}

}
22 changes: 13 additions & 9 deletions tests/PHPStan/Analyser/data/array-filter-arrow-functions.php
Expand Up @@ -5,35 +5,39 @@
use function PHPStan\Testing\assertType;

/**
* @param int[] $list1
* @param int[] $list2
* @param int[] $list3
* @param array<int, int> $list1
* @param array<int, int> $list2
* @param array<int, int> $list3
*/
function alwaysEvaluatesToFalse(array $list1, array $list2, array $list3): void
{
$filtered1 = array_filter($list1, static fn($item): bool => is_string($item));
assertType('array{}', $filtered1);

$filtered2 = array_filter($list2, static fn($item): bool => is_string($item), ARRAY_FILTER_USE_KEY);
assertType('array<int>', $filtered2); // not supported yet
$filtered2 = array_filter($list2, static fn($key): bool => is_string($key), ARRAY_FILTER_USE_KEY);
assertType('array{}', $filtered2);

$filtered3 = array_filter($list3, static fn($item, $key): bool => is_string($item) && is_string($key), ARRAY_FILTER_USE_BOTH);
assertType('array<int>', $filtered3); // not supported yet
assertType('array{}', $filtered3);
}

/**
* @param array<int|string, int|string> $map1
* @param array<int|string, int|string> $map2
* @param array<int|string, int|string> $map3
* @param array<int|string, int|string> $map4
*/
function filtersString(array $map1, array $map2, array $map3, array $map4): void
{
$filtered1 = array_filter($map1, static fn($item): bool => is_string($item));
assertType('array<int|string, string>', $filtered1);

$filtered2 = array_filter($map2, static fn($item): bool => is_string($item), ARRAY_FILTER_USE_KEY);
assertType('array<int|string, int|string>', $filtered2); // not supported yet
$filtered2 = array_filter($map2, static fn($key): bool => is_string($key), ARRAY_FILTER_USE_KEY);
assertType('array<string, int|string>', $filtered2);

$filtered3 = array_filter($map3, static fn($item, $key): bool => is_string($item) && is_string($key), ARRAY_FILTER_USE_BOTH);
assertType('array<int|string, int|string>', $filtered3); // not supported yet
assertType('array<string, string>', $filtered3);

$filtered4 = array_filter($map4, static fn($item): bool => is_string($item), ARRAY_FILTER_USE_BOTH);
assertType('array<int|string, string>', $filtered4);
}
24 changes: 14 additions & 10 deletions tests/PHPStan/Analyser/data/array-filter-callables.php
Expand Up @@ -5,35 +5,39 @@
use function PHPStan\Testing\assertType;

/**
* @param int[] $list1
* @param int[] $list2
* @param int[] $list3
* @param array<int, int> $list1
* @param array<int, int> $list2
* @param array<int, int> $list3
*/
function alwaysEvaluatesToFalse(array $list1, array $list2, array $list3): void
{
$filtered1 = array_filter($list1, static function ($item): bool { return is_string($item); });
assertType('array{}', $filtered1);

$filtered2 = array_filter($list2, static function ($item): bool { return is_string($item); }, ARRAY_FILTER_USE_KEY);
assertType('array<int>', $filtered2); // not supported yet
$filtered2 = array_filter($list2, static function ($key): bool { return is_string($key); }, ARRAY_FILTER_USE_KEY);
assertType('array{}', $filtered2);

$filtered3 = array_filter($list3, static function ($item, $key): bool { return is_string($item) && is_string($key); }, ARRAY_FILTER_USE_BOTH);
assertType('array<int>', $filtered3); // not supported yet
assertType('array{}', $filtered3);
}

/**
* @param array<int|string, int|string> $map1
* @param array<int|string, int|string> $map2
* @param array<int|string, int|string> $map3
* @param array<int|string, int|string> $map4
*/
function filtersString(array $map1, array $map2, array $map3): void
function filtersString(array $map1, array $map2, array $map3, array $map4): void
{
$filtered1 = array_filter($map1, static function ($item): bool { return is_string($item); });
assertType('array<int|string, string>', $filtered1);

$filtered2 = array_filter($map2, static function ($item): bool { return is_string($item); }, ARRAY_FILTER_USE_KEY);
assertType('array<int|string, int|string>', $filtered2); // not supported yet
$filtered2 = array_filter($map2, static function ($key): bool { return is_string($key); }, ARRAY_FILTER_USE_KEY);
assertType('array<string, int|string>', $filtered2);

$filtered3 = array_filter($map3, static function ($item, $key): bool { return is_string($item) && is_string($key); }, ARRAY_FILTER_USE_BOTH);
assertType('array<int|string, int|string>', $filtered3); // not supported yet
assertType('array<string, string>', $filtered3);

$filtered4 = array_filter($map4, static function ($item): bool { return is_string($item); }, ARRAY_FILTER_USE_BOTH);
assertType('array<int|string, string>', $filtered4);
}
4 changes: 2 additions & 2 deletions tests/PHPStan/Analyser/data/array-filter.php
Expand Up @@ -30,8 +30,8 @@ function withoutCallback(array $map1, array $map2, array $map3): void
assertType('array<string, float|int<min, -1>|int<1, max>|non-empty-string|true>', $filtered1);

$filtered2 = array_filter($map2, null, ARRAY_FILTER_USE_KEY);
assertType('array<string, bool|float|int|string>', $filtered2); // not supported yet
assertType('array<string, float|int<min, -1>|int<1, max>|non-empty-string|true>', $filtered2);

$filtered3 = array_filter($map3, null, ARRAY_FILTER_USE_BOTH);
assertType('array<string, bool|float|int|string>', $filtered3); // not supported yet
assertType('array<string, float|int<min, -1>|int<1, max>|non-empty-string|true>', $filtered3);
}