diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 6c15006..1e43cd9 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -13,7 +13,7 @@ jobs:
strategy:
matrix:
- php: ['7.3', '7.4', '8.0']
+ php: ['8.1','8.2','8.3','8.4']
steps:
- name: "Checkout"
@@ -26,7 +26,7 @@ jobs:
extensions: "json, dom, mbstring"
- name: "Cache dependencies"
- uses: "actions/cache@v1.1.2"
+ uses: "actions/cache@v4"
with:
path: "~/.composer/cache"
key: "php-${{ matrix.php }}-composer-cache-${{ hashFiles('**/composer.json') }}"
@@ -44,7 +44,7 @@ jobs:
strategy:
matrix:
- php: ['7.3', '7.4', '8.0']
+ php: ['8.1','8.2','8.3','8.4']
steps:
- name: "Checkout"
@@ -57,7 +57,7 @@ jobs:
extensions: "json, dom, mbstring"
- name: "Cache dependencies"
- uses: "actions/cache@v1.1.2"
+ uses: "actions/cache@v4"
with:
path: "~/.composer/cache"
key: "php-${{ matrix.php }}-composer-cache-${{ hashFiles('**/composer.json') }}"
@@ -75,7 +75,7 @@ jobs:
strategy:
matrix:
- php: ['8.0']
+ php: ['8.1']
steps:
- name: "Checkout"
@@ -91,7 +91,7 @@ jobs:
run: "composer validate"
- name: "Cache dependencies"
- uses: "actions/cache@v1.1.2"
+ uses: "actions/cache@v4"
with:
path: "~/.composer/cache"
key: "php-${{ matrix.php }}-composer-cache-${{ hashFiles('**/composer.json') }}"
diff --git a/Makefile b/Makefile
index 022c819..ab54308 100644
--- a/Makefile
+++ b/Makefile
@@ -11,16 +11,16 @@ test:
$(MAKE) fmt-check
phpstan:
- docker run -it --rm -v ${PWD}:/app -w /app php:7.3-cli-alpine php -d error_reporting=-1 -d memory_limit=-1 bin/phpstan --ansi analyse
+ docker run -it --rm -v ${PWD}:/app -w /app php:8.4-cli-alpine php -d memory_limit=-1 bin/phpstan --ansi analyse
phpstan-clear-cache:
- docker run -it --rm -v ${PWD}:/app -w /app php:7.3-cli-alpine php -d error_reporting=-1 -d memory_limit=-1 bin/phpstan --ansi clear-result-cache
+ docker run -it --rm -v ${PWD}:/app -w /app php:8.4-cli-alpine php -d error_reporting=-1 -d memory_limit=-1 bin/phpstan --ansi clear-result-cache
phpunit:
- docker run -it --rm -v ${PWD}:/app -w /app php:7.3-cli-alpine php -d error_reporting=-1 bin/phpunit --colors=always -c phpunit.xml
+ docker run -it --rm -v ${PWD}:/app -w /app php:8.4-cli-alpine php -d error_reporting=-1 bin/phpunit --colors=always -c phpunit.xml
fmt-check:
- docker run -it --rm -v ${PWD}:/app -w /app php:7.3-cli-alpine php bin/phpcs --standard=./ruleset.xml --extensions=php --tab-width=4 -sp ./src ./tests
+ docker run -it --rm -v ${PWD}:/app -w /app php:8.4-cli-alpine php bin/phpcs --standard=./ruleset.xml --extensions=php --tab-width=4 -sp ./src ./tests
fmt:
- docker run -it --rm -v ${PWD}:/app -w /app php:7.3-cli-alpine php bin/phpcbf --standard=./ruleset.xml --extensions=php --tab-width=4 -sp ./src ./tests
+ docker run -it --rm -v ${PWD}:/app -w /app php:8.4-cli-alpine php bin/phpcbf --standard=./ruleset.xml --extensions=php --tab-width=4 -sp ./src ./tests
diff --git a/composer.json b/composer.json
index ed9a136..ef1d79a 100644
--- a/composer.json
+++ b/composer.json
@@ -1,4 +1,5 @@
{
+ "version": "0.5.0",
"name": "bonami/phpstan-collections",
"type": "phpstan-extension",
"description": "Phpstan extension for bonami/collections library",
@@ -10,8 +11,8 @@
}
],
"require": {
- "php": ">=7.3|^8.0",
- "phpstan/phpstan": "^1.0"
+ "php": "^8.1",
+ "phpstan/phpstan": "^2.0"
},
"require-dev": {
"roave/security-advisories": "dev-latest",
@@ -19,7 +20,7 @@
"phpunit/phpunit": "^9.4.2",
"slevomat/coding-standard": "^6.4.1",
"squizlabs/php_codesniffer": "^3.5.0",
- "bonami/collections": "^0.4.5"
+ "bonami/collections": "0.6.0"
},
"config": {
"bin-dir": "bin",
@@ -30,7 +31,7 @@
},
"extra": {
"branch-alias": {
- "dev-master": "0.4.x-dev"
+ "dev-master": "0.5.x-dev"
},
"phpstan": {
"includes": [
diff --git a/extension.neon b/extension.neon
index 591b3aa..633f63a 100644
--- a/extension.neon
+++ b/extension.neon
@@ -1,4 +1,16 @@
services:
+ -
+ class: Bonami\Collection\Phpstan\IterableFilterTypeNarrowingExtension
+ arguments:
+ - 'Bonami\Collection\ArrayList'
+ tags:
+ - phpstan.broker.dynamicMethodReturnTypeExtension
+ -
+ class: Bonami\Collection\Phpstan\IterableFilterTypeNarrowingExtension
+ arguments:
+ - 'Bonami\Collection\LazyList'
+ tags:
+ - phpstan.broker.dynamicMethodReturnTypeExtension
-
class: Bonami\Collection\Phpstan\ArrayListWithoutNullsReturnTypeExtension
tags:
@@ -10,7 +22,7 @@ services:
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
-
class: Bonami\Collection\Phpstan\LateStaticBindingMethodReturnTypeExtension
- factory: Bonami\Collection\Phpstan\LateStaticBindingMethodReturnTypeExtension::forMethods('Bonami\Collection\ArrayList', ['uniqueBy', 'unique', 'union', 'filter', 'sort', 'take', 'slice', 'minus', 'minusOne', 'concat', 'intersect', 'reverse'])
+ factory: Bonami\Collection\Phpstan\LateStaticBindingMethodReturnTypeExtension::forMethods('Bonami\Collection\ArrayList', ['uniqueBy', 'unique', 'union', 'sort', 'take', 'slice', 'minus', 'minusOne', 'concat', 'intersect', 'reverse'])
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
-
@@ -30,7 +42,7 @@ services:
- phpstan.broker.dynamicStaticMethodReturnTypeExtension
-
class: Bonami\Collection\Phpstan\LateStaticBindingMethodReturnTypeExtension
- factory: Bonami\Collection\Phpstan\LateStaticBindingMethodReturnTypeExtension::forMethods('Bonami\Collection\LazyList', ['take', 'filter', 'dropWhile', 'drop', 'concat', 'add', 'insertOnPosition'])
+ factory: Bonami\Collection\Phpstan\LateStaticBindingMethodReturnTypeExtension::forMethods('Bonami\Collection\LazyList', ['take', 'dropWhile', 'drop', 'concat', 'add', 'insertOnPosition'])
tags:
- phpstan.broker.dynamicMethodReturnTypeExtension
-
diff --git a/phpstan.neon b/phpstan.neon
index 90931bc..0ee8d28 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -2,6 +2,8 @@ includes:
- extension.neon
- vendor/phpstan/phpstan/conf/bleedingEdge.neon
parameters:
+ ignoreErrors:
+ - '~Doing instanceof PHPStan\\Type\\Generic\\GenericObjectType is error-prone and deprecated~'
reportUnmatchedIgnoredErrors: true
level: 9
paths:
diff --git a/ruleset.xml b/ruleset.xml
index de69974..4e268e8 100644
--- a/ruleset.xml
+++ b/ruleset.xml
@@ -59,7 +59,6 @@
-
diff --git a/src/Bonami/Collection/Phpstan/GroupByMethodReturnTypeExtension.php b/src/Bonami/Collection/Phpstan/GroupByMethodReturnTypeExtension.php
index 913e840..3c1a4c9 100644
--- a/src/Bonami/Collection/Phpstan/GroupByMethodReturnTypeExtension.php
+++ b/src/Bonami/Collection/Phpstan/GroupByMethodReturnTypeExtension.php
@@ -9,7 +9,6 @@
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
-use PHPStan\Type\CallableType;
use PHPStan\Type\ClosureType;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\Generic\GenericObjectType;
@@ -18,9 +17,10 @@
class GroupByMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
- /** @var string */
- private $class;
+ /** @var class-string */
+ private string $class;
+ /** @param class-string $class */
public function __construct(string $class)
{
$this->class = $class;
@@ -43,8 +43,9 @@ public function getTypeFromMethodCall(
): Type {
$arg = $methodCall->args[0];
assert($arg instanceof Arg);
+
$closure = $scope->getType($arg->value);
- assert($closure instanceof ClosureType || $closure instanceof CallableType);
+ assert($closure instanceof ClosureType);
$listType = $scope->getType($methodCall->var);
diff --git a/src/Bonami/Collection/Phpstan/Helpers.php b/src/Bonami/Collection/Phpstan/Helpers.php
new file mode 100644
index 0000000..fcdcd97
--- /dev/null
+++ b/src/Bonami/Collection/Phpstan/Helpers.php
@@ -0,0 +1,33 @@
+expr;
+ }
+
+ if (!($closure instanceof Expr\Closure)) {
+ return null;
+ }
+
+ if (!isset($closure->stmts[0])) {
+ return null;
+ }
+
+ if (!($closure->stmts[0] instanceof Return_)) {
+ return null;
+ }
+
+ return $closure->stmts[0]->expr;
+ }
+}
diff --git a/src/Bonami/Collection/Phpstan/IterableFilterTypeNarrowingExtension.php b/src/Bonami/Collection/Phpstan/IterableFilterTypeNarrowingExtension.php
new file mode 100644
index 0000000..92a9ded
--- /dev/null
+++ b/src/Bonami/Collection/Phpstan/IterableFilterTypeNarrowingExtension.php
@@ -0,0 +1,110 @@
+class = $class;
+ }
+
+ public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
+ {
+ $this->typeSpecifier = $typeSpecifier;
+ }
+
+ public function getClass(): string
+ {
+ return $this->class;
+ }
+
+ public function isMethodSupported(MethodReflection $methodReflection): bool
+ {
+ return $methodReflection->getName() === 'filter';
+ }
+
+ public function getTypeFromMethodCall(
+ MethodReflection $methodReflection,
+ MethodCall $methodCall,
+ Scope $scope
+ ): ?Type {
+ $type = $scope->getType($methodCall->var);
+
+ if (!$type instanceof GenericObjectType) {
+ return $type;
+ }
+
+ $args = $methodCall->getArgs();
+ if (!array_key_exists(0, $args)) {
+ return $type;
+ }
+
+ $arg = $methodCall->getArgs()[0]->value;
+ if (!($arg instanceof Closure || $arg instanceof ArrowFunction)) {
+ return $type;
+ }
+
+ $expr = Helpers::getSimpleClosureExpression($arg);
+ if ($expr !== null) {
+ $specifiedTypes = $this->typeSpecifier
+ ->specifyTypesInCondition($scope, $expr, TypeSpecifierContext::createTruthy());
+
+ if (count($specifiedTypes->getSureTypes()) !== 0) {
+ return new GenericObjectType(
+ $methodReflection->getDeclaringClass()->getName(),
+ array_values(array_map(
+ static function (array $pair) use ($type) {
+ return TypeCombinator::intersect($type->getIterableValueType(), $pair[1]);
+ },
+ $specifiedTypes->getSureTypes(),
+ ))
+ );
+ }
+
+ if (count($specifiedTypes->getSureNotTypes()) !== 0) {
+ return new GenericObjectType(
+ $methodReflection->getDeclaringClass()->getName(),
+ array_values(array_map(
+ static function (array $pair) use ($type) {
+ return TypeCombinator::remove($type->getIterableValueType(), $pair[1]);
+ },
+ $specifiedTypes->getSureNotTypes(),
+ ))
+ );
+ }
+ return $type;
+ }
+
+ return $type;
+ }
+}
diff --git a/src/Bonami/Collection/Phpstan/LateStaticBindingMethodReturnTypeExtension.php b/src/Bonami/Collection/Phpstan/LateStaticBindingMethodReturnTypeExtension.php
index 3162ce4..b575fb0 100644
--- a/src/Bonami/Collection/Phpstan/LateStaticBindingMethodReturnTypeExtension.php
+++ b/src/Bonami/Collection/Phpstan/LateStaticBindingMethodReturnTypeExtension.php
@@ -12,14 +12,14 @@
class LateStaticBindingMethodReturnTypeExtension implements DynamicMethodReturnTypeExtension
{
- /** @var string */
- private $class;
+ /** @var class-string */
+ private string $class;
/** @var array */
- private $methods;
+ private array $methods;
/**
- * @param string $class
+ * @param class-string $class
* @param array $methods
*/
private function __construct(string $class, array $methods)
@@ -29,7 +29,7 @@ private function __construct(string $class, array $methods)
}
/**
- * @param string $class
+ * @param class-string $class
* @param array $methods
*/
public static function forMethods(string $class, array $methods): self
diff --git a/src/Bonami/Collection/Phpstan/LateStaticBindingStaticMethodReturnTypeExtension.php b/src/Bonami/Collection/Phpstan/LateStaticBindingStaticMethodReturnTypeExtension.php
index 9e50026..8b96ed3 100644
--- a/src/Bonami/Collection/Phpstan/LateStaticBindingStaticMethodReturnTypeExtension.php
+++ b/src/Bonami/Collection/Phpstan/LateStaticBindingStaticMethodReturnTypeExtension.php
@@ -17,14 +17,14 @@
class LateStaticBindingStaticMethodReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension
{
- /** @var string */
- private $class;
+ /** @var class-string */
+ private string $class;
/** @var array */
- private $methods;
+ private array $methods;
/**
- * @param string $class
+ * @param class-string $class
* @param array $methods
*/
private function __construct(string $class, array $methods)
@@ -34,7 +34,7 @@ private function __construct(string $class, array $methods)
}
/**
- * @param string $class
+ * @param class-string $class
* @param array $methods
*/
public static function forMethods(string $class, array $methods): self
@@ -60,8 +60,8 @@ public function getTypeFromStaticMethodCall(
$calledClassExpr = $methodCall->class;
if ($calledClassExpr instanceof PropertyFetch) {
$type = $scope->getType($calledClassExpr);
- if ($type instanceof GenericClassStringType) {
- return $type->getGenericType();
+ if ($type->isClassString()->yes()) {
+ return $type->getClassStringObjectType();
}
}
diff --git a/tests/Bonami/Collection/Phpstan/ArrayListTest.php b/tests/Bonami/Collection/Phpstan/ArrayListTest.php
index 939a627..95bd2c3 100644
--- a/tests/Bonami/Collection/Phpstan/ArrayListTest.php
+++ b/tests/Bonami/Collection/Phpstan/ArrayListTest.php
@@ -157,6 +157,33 @@ public function testWithoutNullsReturnType(): void
self::assertInstanceOf(FooArrayList::class, $concreteList);
}
+ public function testItShouldFilterNullsFromGenericType(): void
+ {
+ $withNulls = ArrayList::fromIterable([new Foo(), null]);
+ $withoutNulls = $withNulls->filter(static function (?Foo $foo): bool {
+ return $foo !== null;
+ });
+ $this->requireArrayListOfFoo($withoutNulls);
+ $this->requireArrayListOfFoo($withNulls->filter(static function (?Foo $foo): bool {
+ return $foo !== null;
+ }));
+ $this->requireArrayListOfFoo($withNulls->filter(static fn (?Foo $foo): bool => $foo !== null));
+ self::assertInstanceOf(ArrayList::class, $withoutNulls);
+
+ $fooList = FooArrayList::fromIterable([new Foo(), null])
+ ->filter(static fn (?Foo $foo) => $foo !== null);
+
+ $this->requireFooList($fooList);
+ }
+
+ public function testItShouldFilterTypesFromUnions(): void
+ {
+ $intsAndStrings = ArrayList::fromIterable([1, 'string']);
+ $this->requireIntsAndStrings($intsAndStrings);
+ $this->requireInts($intsAndStrings->filter(fn ($x) => is_int($x)));
+ $this->requireStrings($intsAndStrings->filter(fn ($x) => !is_int($x)));
+ }
+
public function testMinusReturnType(): void
{
$genericList = ArrayList::fromIterable([new Foo()])->minus([new Foo()]);
@@ -250,4 +277,19 @@ public function requireMapArrayListByFoo(Map $list): void
public function requireMapFooArrayListByFoo(Map $list): void
{
}
+
+ /** @param ArrayList $intsAndStrings */
+ private function requireIntsAndStrings(ArrayList $intsAndStrings): void
+ {
+ }
+
+ /** @param ArrayList $intsAndStrings */
+ private function requireInts(ArrayList $intsAndStrings): void
+ {
+ }
+
+ /** @param ArrayList $intsAndStrings */
+ private function requireStrings(ArrayList $intsAndStrings): void
+ {
+ }
}
diff --git a/tests/Bonami/Collection/Phpstan/LazyListTest.php b/tests/Bonami/Collection/Phpstan/LazyListTest.php
index 23789be..3a71bcb 100644
--- a/tests/Bonami/Collection/Phpstan/LazyListTest.php
+++ b/tests/Bonami/Collection/Phpstan/LazyListTest.php
@@ -4,7 +4,6 @@
namespace Bonami\Collection\Phpstan;
-use ArrayIterator;
use Bonami\Collection\LazyList;
use PHPUnit\Framework\TestCase;
@@ -80,6 +79,25 @@ public function testFilterReturnType(): void
self::assertInstanceOf(FooLazyList::class, $concreteList);
}
+ public function testItShouldNarrowTypesAfterFilter(): void
+ {
+ $withNulls = LazyList::fromIterable([new Foo(), null]);
+ $withoutNulls = $withNulls->filter(static function (?Foo $foo): bool {
+ return $foo !== null;
+ });
+ $this->requireLazyListOfFoo($withoutNulls);
+ $this->requireLazyListOfFoo($withNulls->filter(static function (?Foo $foo): bool {
+ return $foo !== null;
+ }));
+ $this->requireLazyListOfFoo($withNulls->filter(static fn (?Foo $foo): bool => $foo !== null));
+ self::assertInstanceOf(LazyList::class, $withoutNulls);
+
+ $fooList = FooLazyList::fromIterable([new Foo(), null])
+ ->filter(static fn (?Foo $foo) => $foo !== null);
+
+ $this->requireFooList($fooList);
+ }
+
public function testDropWhileReturnType(): void
{
$tautology = static function () {
diff --git a/tests/Bonami/Collection/Phpstan/MapTest.php b/tests/Bonami/Collection/Phpstan/MapTest.php
index 04a8222..42125ba 100644
--- a/tests/Bonami/Collection/Phpstan/MapTest.php
+++ b/tests/Bonami/Collection/Phpstan/MapTest.php
@@ -187,7 +187,7 @@ public function testSortValuesReturnType(): void
self::assertInstanceOf(FooMap::class, $concreteList);
}
- /** @phpstan-param Map $list */
+ /** @phpstan-param Map $list */
public function requireMapOfFoo(Map $list): void
{
}