Skip to content

Commit 7af78dd

Browse files
committed
More precise types after Option::filter()
1 parent d896f81 commit 7af78dd

File tree

5 files changed

+227
-0
lines changed

5 files changed

+227
-0
lines changed

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
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+
19+
class OptionFilterReturnTypeExtension implements DynamicMethodReturnTypeExtension
20+
{
21+
22+
public function getClass(): string
23+
{
24+
/** @var class-string */
25+
return 'Psl\Option\Option'; // @phpstan-ignore varTag.nativeType
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'); // @phpstan-ignore argument.type
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: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
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+
}
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 PHPUnit\Runner\PhptTestCase;
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.0.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: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PslOptionTest;
6+
7+
use Closure;
8+
use Psl\Option;
9+
10+
use function PHPStan\Testing\assertType;
11+
12+
13+
function positive_int(int $value): void
14+
{
15+
$option = Option\some($value);
16+
$option = $option->filter(fn($value) => $value > 0);
17+
assertType('Psl\Option\Option<int<1, max>>', $option);
18+
19+
$option = Option\some($value);
20+
$option = $option->filter(Closure::fromCallable([\Psl\Type\positive_int(), 'matches']));
21+
assertType('Psl\Option\Option<int<1, max>>', $option);
22+
}
23+
24+
function non_empty_string(string $value): void
25+
{
26+
$option = Option\some($value);
27+
$option = $option->filter(fn($value) => '' !== $value);
28+
assertType('Psl\Option\Option<non-empty-string>', $option);
29+
30+
$option = Option\some($value);
31+
$option = $option->filter(Closure::fromCallable([\Psl\Type\non_empty_string(), 'matches']));
32+
assertType('Psl\Option\Option<non-empty-string>', $option);
33+
}
34+
35+
36+
function numeric_string(string $value): void
37+
{
38+
$option = Option\some($value);
39+
$option = $option->filter(fn($value) => is_numeric($value));
40+
assertType('Psl\Option\Option<numeric-string>', $option);
41+
42+
$option = Option\some($value);
43+
$option = $option->filter(is_numeric(...));
44+
assertType('Psl\Option\Option<numeric-string>', $option);
45+
46+
$option = Option\some($value);
47+
$option = $option->filter(Closure::fromCallable([\Psl\Type\numeric_string(), 'matches']));
48+
assertType('Psl\Option\Option<numeric-string>', $option);
49+
}
50+
51+
function literal_string(string $value): void
52+
{
53+
$option = Option\some($value);
54+
$option = $option->filter(fn($value) => 'potato' === $value);
55+
assertType('Psl\Option\Option<\'potato\'>', $option);
56+
57+
$option = Option\some($value);
58+
$option = $option->filter(fn ($value) => 'potato' === $value || 'tomato' === $value);
59+
assertType('Psl\Option\Option<\'potato\'|\'tomato\'>', $option);
60+
61+
$option = Option\some($value);
62+
$option = $option->filter(fn ($value) => in_array($value, ['potato', 'tomato'], true));
63+
assertType('Psl\Option\Option<\'potato\'|\'tomato\'>', $option);
64+
65+
$option = Option\some($value);
66+
$option = $option->filter(Closure::fromCallable([\Psl\Type\literal_scalar('potato'), 'matches']));
67+
assertType('Psl\Option\Option<\'potato\'>', $option);
68+
}
69+
70+
71+
/**
72+
* @param list<float> $value
73+
*/
74+
function filter_list(array $value): void
75+
{
76+
$option = Option\some($value);
77+
assertType('Psl\Option\Option<list<float>>', $option);
78+
$option = $option->filter(fn($value) => [] !== $value);
79+
assertType('Psl\Option\Option<non-empty-list<float>>', $option);
80+
81+
$option = Option\some($value);
82+
assertType('Psl\Option\Option<list<float>>', $option);
83+
$option = $option->filter(Closure::fromCallable([\Psl\Type\non_empty_vec(), 'matches']));
84+
assertType('Psl\Option\Option<non-empty-list<float>>', $option);
85+
}
86+

0 commit comments

Comments
 (0)