Skip to content

Commit eaf787d

Browse files
devnixondrejmirtes
authored andcommitted
More precise types after Option::filter()
1 parent d896f81 commit eaf787d

File tree

8 files changed

+309
-1
lines changed

8 files changed

+309
-1
lines changed

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ cs:
1616

1717
.PHONY: cs-fix
1818
cs-fix:
19-
vendor/bin/phpcbf
19+
build-cs/vendor/bin/phpcbf
2020

2121
.PHONY: phpstan
2222
phpstan:

extension.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
parameters:
22
stubFiles:
3+
- stubs/Option.stub
34
- stubs/optional.stub
45
- stubs/OptionalType.stub
56
- stubs/Type.stub
@@ -20,3 +21,8 @@ services:
2021
class: Psl\PHPStan\Type\MatchesTypeSpecifyingExtension
2122
tags:
2223
- phpstan.typeSpecifier.methodTypeSpecifyingExtension
24+
25+
-
26+
class: Psl\PHPStan\Option\OptionFilterReturnTypeExtension
27+
tags:
28+
- phpstan.broker.dynamicMethodReturnTypeExtension

phpstan-baseline-psl-1.neon

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
parameters:
2+
ignoreErrors:
3+
-
4+
message: '#^Class Psl\\Option\\Option not found\.$#'
5+
identifier: class.notFound
6+
count: 1
7+
path: src/Option/OptionFilterReturnTypeExtension.php
8+
9+
-
10+
message: '#^Parameter \#1 \$ancestorClassName of method PHPStan\\Type\\Type\:\:getTemplateType\(\) expects class\-string, string given\.$#'
11+
identifier: argument.type
12+
count: 1
13+
path: src/Option/OptionFilterReturnTypeExtension.php

phpstan.neon

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
includes:
22
- extension.neon
3+
- phpstan-baseline-psl-1.neon
34
- vendor/phpstan/phpstan-phpunit/extension.neon
45
- vendor/phpstan/phpstan-phpunit/rules.neon
56
- vendor/phpstan/phpstan-strict-rules/rules.neon
@@ -8,3 +9,19 @@ includes:
89
parameters:
910
excludePaths:
1011
- tests/*/data/*
12+
13+
reportUnmatchedIgnoredErrors: false
14+
15+
ignoreErrors:
16+
-
17+
message: '~^Parameter #1 \$assertType of method Psl\\PHPStan\\Option\\PslTypeSpecifyingExtensionTest::testFileAsserts\(\) expects string, mixed given\.~'
18+
path: tests/Option/PslTypeSpecifyingExtensionTest.php
19+
-
20+
message: '~^Parameter #2 \$file of method Psl\\PHPStan\\Option\\PslTypeSpecifyingExtensionTest::testFileAsserts\(\) expects string, mixed given\.~'
21+
path: tests/Option/PslTypeSpecifyingExtensionTest.php
22+
-
23+
message: '~^Parameter #1 \$assertType of method Psl\\PHPStan\\Type\\PslTypeSpecifyingExtensionTest::testFileAsserts\(\) expects string, mixed given\.~'
24+
path: tests/Type/PslTypeSpecifyingExtensionTest.php
25+
-
26+
message: '~^Parameter #2 \$file of method Psl\\PHPStan\\Type\\PslTypeSpecifyingExtensionTest::testFileAsserts\(\) expects string, mixed given\.~'
27+
path: tests/Type/PslTypeSpecifyingExtensionTest.php
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Psl\PHPStan\Option;
4+
5+
use PhpParser\Node\Arg;
6+
use PhpParser\Node\Expr;
7+
use PhpParser\Node\Expr\FuncCall;
8+
use PhpParser\Node\Expr\MethodCall;
9+
use PhpParser\Node\Name;
10+
use PHPStan\Analyser\Scope;
11+
use PHPStan\Node\Expr\TypeExpr;
12+
use PHPStan\Reflection\MethodReflection;
13+
use PHPStan\Type\ArrayType;
14+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
15+
use PHPStan\Type\Generic\GenericObjectType;
16+
use PHPStan\Type\IntegerType;
17+
use PHPStan\Type\Type;
18+
use Psl\Option\Option;
19+
20+
class OptionFilterReturnTypeExtension implements DynamicMethodReturnTypeExtension
21+
{
22+
23+
public function getClass(): string
24+
{
25+
return Option::class;
26+
}
27+
28+
public function isMethodSupported(MethodReflection $methodReflection): bool
29+
{
30+
return $methodReflection->getName() === 'filter';
31+
}
32+
33+
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
34+
{
35+
$args = $methodCall->getArgs();
36+
if (!isset($args[0])) {
37+
return null;
38+
}
39+
$filterCallback = $args[0]->value;
40+
41+
$optionType = $scope->getType($methodCall->var);
42+
$originalType = $optionType->getTemplateType('Psl\Option\Option', 'T');
43+
44+
$refinedType = $this->analyzeFilterCallback($filterCallback, $originalType, $scope);
45+
46+
return new GenericObjectType(
47+
'Psl\Option\Option',
48+
[$refinedType]
49+
);
50+
}
51+
52+
private function analyzeFilterCallback(Expr $filterCallback, Type $originalType, Scope $scope): Type
53+
{
54+
$arrayType = new ArrayType(new IntegerType(), $originalType);
55+
56+
$refinedType = $scope
57+
->getType(
58+
new FuncCall(
59+
new Name('array_filter'),
60+
[new Arg(new TypeExpr($arrayType)), new Arg($filterCallback)]
61+
)
62+
)
63+
->getIterableValueType();
64+
65+
if (!$refinedType->equals($originalType)) {
66+
return $refinedType;
67+
}
68+
69+
return $originalType;
70+
}
71+
72+
}

stubs/Option.stub

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
3+
namespace Psl\Option;
4+
5+
use Closure;
6+
use Psl\Comparison;
7+
use Psl\Type;
8+
9+
/**
10+
* @template T
11+
*
12+
* @readonly
13+
*/
14+
final class Option
15+
{
16+
17+
/**
18+
* Returns none if the option is none, otherwise calls `$predicate` with the wrapped value and returns:
19+
* - Option<T>::some() if `$predicate` returns true (where t is the wrapped value), and
20+
* - Option<T>::none() if `$predicate` returns false.
21+
*
22+
* @param (Closure(T): bool) $predicate
23+
*
24+
* @param-immediately-invoked-callable $predicate
25+
*
26+
* @return Option<T>
27+
*/
28+
public function filter(Closure $predicate): Option
29+
{
30+
}
31+
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Psl\PHPStan\Option;
4+
5+
use Composer\InstalledVersions;
6+
use Composer\Semver\VersionParser;
7+
use PHPStan\Testing\TypeInferenceTestCase;
8+
use PHPUnit\Framework\Assert;
9+
use function sprintf;
10+
11+
class PslTypeSpecifyingExtensionTest extends TypeInferenceTestCase
12+
{
13+
14+
/**
15+
* @return iterable<mixed>
16+
*/
17+
public function dataFileAsserts(): iterable
18+
{
19+
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter.php');
20+
}
21+
22+
/**
23+
* @dataProvider dataFileAsserts
24+
* @param mixed ...$args
25+
*/
26+
public function testFileAsserts(
27+
string $assertType,
28+
string $file,
29+
...$args
30+
): void
31+
{
32+
if (!InstalledVersions::satisfies(new VersionParser(), 'azjezz/psl', '>=2.2.0')) {
33+
Assert::markTestSkipped(sprintf('Option component is not available in current azjezz/psl installed version'));
34+
}
35+
36+
$this->assertFileAsserts($assertType, $file, ...$args);
37+
}
38+
39+
/***
40+
* @return string[]
41+
*/
42+
public static function getAdditionalConfigFiles(): array
43+
{
44+
return [__DIR__ . '/../../extension.neon'];
45+
}
46+
47+
}

tests/Option/data/filter.php

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types=1);
4+
5+
namespace PslOptionTest;
6+
7+
use Closure;
8+
use Psl\Option;
9+
use Psl\Type;
10+
11+
use function PHPStan\Testing\assertType;
12+
13+
14+
function positive_int(int $value): void
15+
{
16+
$option = Option\some($value);
17+
$option = $option->filter(fn($value) => $value > 0);
18+
assertType('Psl\Option\Option<int<1, max>>', $option);
19+
20+
$option = Option\some($value);
21+
$option = $option->filter(Type\positive_int()->matches(...));
22+
assertType('Psl\Option\Option<int<1, max>>', $option);
23+
24+
assertType(
25+
'Psl\Option\Option<*NEVER*>',
26+
$option->filter(Type\literal_scalar(0)->matches(...))
27+
);
28+
}
29+
30+
function non_empty_string(string $value): void
31+
{
32+
$option = Option\some($value);
33+
$option = $option->filter(fn($value) => '' !== $value);
34+
assertType('Psl\Option\Option<non-empty-string>', $option);
35+
36+
$option = Option\some($value);
37+
$option = $option->filter(Type\non_empty_string()->matches(...));
38+
assertType('Psl\Option\Option<non-empty-string>', $option);
39+
40+
assertType(
41+
'Psl\Option\Option<non-empty-string>',
42+
$option->filter(Type\string()->matches(...))
43+
);
44+
45+
assertType(
46+
'Psl\Option\Option<*NEVER*>',
47+
$option->filter(Type\literal_scalar('')->matches(...))
48+
);
49+
}
50+
51+
52+
function numeric_string(string $value): void
53+
{
54+
$option = Option\some($value);
55+
$option = $option->filter(fn($value) => is_numeric($value));
56+
assertType('Psl\Option\Option<numeric-string>', $option);
57+
58+
$option = Option\some($value);
59+
$option = $option->filter(is_numeric(...));
60+
assertType('Psl\Option\Option<numeric-string>', $option);
61+
62+
$option = Option\some($value);
63+
$option = $option->filter(Type\numeric_string()->matches(...));
64+
assertType('Psl\Option\Option<numeric-string>', $option);
65+
66+
assertType(
67+
'Psl\Option\Option<*NEVER*>',
68+
$option->filter(Type\int()->matches(...))
69+
);
70+
}
71+
72+
function literal_string(string $value): void
73+
{
74+
$option = Option\some($value);
75+
$option = $option->filter(fn($value) => 'potato' === $value);
76+
assertType('Psl\Option\Option<\'potato\'>', $option);
77+
78+
$option = Option\some($value);
79+
$option = $option->filter(fn ($value) => 'potato' === $value || 'tomato' === $value);
80+
assertType('Psl\Option\Option<\'potato\'|\'tomato\'>', $option);
81+
82+
$option = Option\some($value);
83+
$option = $option->filter(fn ($value) => in_array($value, ['potato', 'tomato'], true));
84+
assertType('Psl\Option\Option<\'potato\'|\'tomato\'>', $option);
85+
86+
assertType(
87+
'Psl\Option\Option<*NEVER*>',
88+
$option->filter(Type\int()->matches(...))
89+
);
90+
91+
assertType(
92+
'Psl\Option\Option<\'potato\'>',
93+
$option->filter(Type\literal_scalar('potato')->matches(...))
94+
);
95+
96+
assertType(
97+
'Psl\Option\Option<\'potato\'|\'tomato\'>',
98+
$option->filter(Type\string()->matches(...))
99+
);
100+
101+
$option = Option\some($value);
102+
$option = $option->filter(Type\literal_scalar('potato')->matches(...));
103+
assertType('Psl\Option\Option<\'potato\'>', $option);
104+
}
105+
106+
107+
/**
108+
* @param list<float> $value
109+
*/
110+
function filter_list(array $value): void
111+
{
112+
$option = Option\some($value);
113+
assertType('Psl\Option\Option<list<float>>', $option);
114+
$option = $option->filter(fn($value) => [] !== $value);
115+
assertType('Psl\Option\Option<non-empty-list<float>>', $option);
116+
117+
$option = Option\some($value);
118+
assertType('Psl\Option\Option<list<float>>', $option);
119+
$option = $option->filter(Type\non_empty_vec(Type\float())->matches(...));
120+
assertType('Psl\Option\Option<non-empty-list<float>>', $option);
121+
}
122+

0 commit comments

Comments
 (0)