Skip to content

Commit

Permalink
feat: adds dynmaic method return type extension for Enumerable::filter
Browse files Browse the repository at this point in the history
  • Loading branch information
canvural committed Oct 26, 2021
1 parent e31b471 commit 8fa4d79
Show file tree
Hide file tree
Showing 5 changed files with 183 additions and 1 deletion.
5 changes: 5 additions & 0 deletions extension.neon
Expand Up @@ -296,6 +296,11 @@ services:
tags:
- phpstan.broker.dynamicFunctionReturnTypeExtension

-
class: NunoMaduro\Larastan\ReturnTypes\CollectionDynamicReturnTypeExtension
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension

-
class: NunoMaduro\Larastan\Types\AbortIfFunctionTypeSpecifyingExtension
tags:
Expand Down
2 changes: 1 addition & 1 deletion src/ApplicationResolver.php
Expand Up @@ -37,7 +37,7 @@ public static function resolve(): Application
self::$composer = json_decode((string) file_get_contents($composerFile), true);
$namespace = (string) key(self::$composer['autoload']['psr-4']);
$vendorDir = self::$composer['config']['vendor-dir'] ?? dirname($composerFile).DIRECTORY_SEPARATOR.'vendor';
$serviceProviders = array_values(array_filter(self::getProjectClasses($namespace, $vendorDir), static function (string $class) use (
$serviceProviders = array_values(array_filter(self::getProjectClasses($namespace, $vendorDir), static function ($class) use (
$namespace
) {
/** @var class-string $class */
Expand Down
101 changes: 101 additions & 0 deletions src/ReturnTypes/CollectionDynamicReturnTypeExtension.php
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace NunoMaduro\Larastan\ReturnTypes;

use Illuminate\Support\Enumerable;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\Variable;
use PhpParser\Node\Stmt\Return_;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\ParametersAcceptorSelector;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantBooleanType;
use PHPStan\Type\Constant\ConstantFloatType;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
use PHPStan\Type\NullType;
use PHPStan\Type\Type;
use PHPStan\Type\TypeCombinator;
use PHPStan\Type\UnionType;

class CollectionDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
public function getClass(): string
{
return Enumerable::class;
}

public function isMethodSupported(MethodReflection $methodReflection): bool
{
return $methodReflection->getName() === 'filter';
}

public function getTypeFromMethodCall(
MethodReflection $methodReflection,
MethodCall $methodCall,
Scope $scope
): Type {
$calledOnType = $scope->getType($methodCall->var);

if (! $calledOnType instanceof \PHPStan\Type\Generic\GenericObjectType) {
return ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType();
}

$keyType = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TKey');
$valueType = $methodReflection->getDeclaringClass()->getActiveTemplateTypeMap()->getType('TValue');

if ($keyType === null || $valueType === null) {
return ParametersAcceptorSelector::selectSingle($methodReflection->getVariants())->getReturnType();
}

if (count($methodCall->getArgs()) < 1) {
$falseyTypes = $this->getFalseyTypes();

return new GenericObjectType($calledOnType->getClassName(), [$keyType, TypeCombinator::remove($valueType, $falseyTypes)]);
}

$callbackArg = $methodCall->getArgs()[0]->value;

$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 ($var !== null && $expr !== null) {
if (!$var instanceof Variable || !is_string($var->name)) {
throw new \PHPStan\ShouldNotHappenException();
}

$itemVariableName = $var->name;

// @phpstan-ignore-next-line
$scope = $scope->assignVariable($itemVariableName, $valueType);
$scope = $scope->filterByTruthyValue($expr);
$valueType = $scope->getVariableType($itemVariableName);
}

return new GenericObjectType($calledOnType->getClassName(), [$keyType, $valueType]);
}

private function getFalseyTypes(): UnionType
{
return new UnionType([new NullType(), new ConstantBooleanType(false), new ConstantIntegerType(0), new ConstantFloatType(0.0), new ConstantStringType(''), new ConstantStringType('0'), new ConstantArrayType([], [])]);
}
}
33 changes: 33 additions & 0 deletions tests/Type/CollectionDynamicReturnTypeExtensionTest.php
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace Tests\Type;

class CollectionDynamicReturnTypeExtensionTest extends \PHPStan\Testing\TypeInferenceTestCase
{
/**
* @return iterable<mixed>
*/
public function dataFileAsserts(): iterable
{
yield from $this->gatherAssertTypes(__DIR__ . '/data/collection-filter.php');
}

/**
* @dataProvider dataFileAsserts
*/
public function testFileAsserts(
string $assertType,
string $file,
...$args
): void
{
$this->assertFileAsserts($assertType, $file, ...$args);
}

public static function getAdditionalConfigFiles(): array
{
return [__DIR__ . '/../../extension.neon'];
}
}
43 changes: 43 additions & 0 deletions tests/Type/data/collection-filter.php
@@ -0,0 +1,43 @@
<?php

namespace CollectionFilter;

use App\Account;
use App\User;
use Illuminate\Support\Collection;
use Illuminate\Database\Eloquent\Collection as EloquentCollection;
use function PHPStan\Testing\assertType;

assertType('Illuminate\Support\Collection<int, non-empty-string>', collect(['foo', null, '', 'bar', null])->filter());

/** @param Collection<int, mixed> $foo */
function foo(Collection $foo): void {
assertType("Illuminate\Support\Collection<int, mixed~0|0.0|''|'0'|array{}|false|null>", $foo->filter());
}

/**
* @param array<int, User> $attachments
*/
function storeAttachments(array $attachments)
{
assertType(
'Illuminate\Support\Collection<int, App\Account>',
collect($attachments)
->map(function (User $attachment): ?Account {
return convertToAccount($attachment);
})
->filter()
);
}

function convertToAccount(User $user): ?Account
{
//
}

assertType('Illuminate\Support\Collection<int, int<3, max>>', collect([1, 2, 3, 4, 5, 6])->filter(function (int $value) { return $value > 2; }));

/** @param EloquentCollection<User> $foo */
function bar(Collection $foo): void {
assertType("Illuminate\Database\Eloquent\Collection<int, App\User>", $foo->filter(function (User $user) { return ! $user->blocked; }));
}

0 comments on commit 8fa4d79

Please sign in to comment.