Skip to content

Commit 861f685

Browse files
committed
PoC
1 parent d896f81 commit 861f685

File tree

4 files changed

+361
-0
lines changed

4 files changed

+361
-0
lines changed

extension.neon

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,9 @@ services:
2020
class: Psl\PHPStan\Type\MatchesTypeSpecifyingExtension
2121
tags:
2222
- phpstan.typeSpecifier.methodTypeSpecifyingExtension
23+
24+
-
25+
class: Psl\PHPStan\Option\OptionFilterReturnTypeExtension
26+
tags:
27+
- phpstan.broker.dynamicMethodReturnTypeExtension
28+
- phpstan.broker.typeSpecifierAwareExtension
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Psl\PHPStan\Option;
4+
5+
use PhpParser\Node\Expr\MethodCall;
6+
use PhpParser\Node\Expr\Variable;
7+
use PhpParser\Node\Expr\ArrowFunction;
8+
use PhpParser\Node\Expr\Closure;
9+
use PhpParser\Node\Expr\FuncCall;
10+
use PhpParser\Node\Arg;
11+
use PHPStan\Analyser\Scope;
12+
use PHPStan\Analyser\TypeSpecifier;
13+
use PHPStan\Analyser\TypeSpecifierAwareExtension;
14+
use PHPStan\Analyser\TypeSpecifierContext;
15+
use PHPStan\Reflection\MethodReflection;
16+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
17+
use PHPStan\Type\Generic\GenericObjectType;
18+
use PHPStan\Type\Type;
19+
use PHPStan\TrinaryLogic;
20+
use Psl\Option\Option;
21+
22+
class OptionFilterReturnTypeExtension implements DynamicMethodReturnTypeExtension, TypeSpecifierAwareExtension
23+
{
24+
private TypeSpecifier $typeSpecifier;
25+
26+
public function setTypeSpecifier(TypeSpecifier $typeSpecifier): void
27+
{
28+
$this->typeSpecifier = $typeSpecifier;
29+
}
30+
31+
public function getClass(): string
32+
{
33+
return Option::class;
34+
}
35+
36+
public function isMethodSupported(MethodReflection $methodReflection): bool
37+
{
38+
return $methodReflection->getName() === 'filter';
39+
}
40+
41+
public function getTypeFromMethodCall(
42+
MethodReflection $methodReflection,
43+
MethodCall $methodCall,
44+
Scope $scope
45+
): ?Type
46+
{
47+
$args = $methodCall->getArgs();
48+
if (!isset($args[0])) {
49+
return null;
50+
}
51+
52+
$optionType = $scope->getType($methodCall->var);
53+
if (!$optionType instanceof GenericObjectType) {
54+
return null;
55+
}
56+
57+
$templateTypes = $optionType->getTypes();
58+
if (count($templateTypes) === 0) {
59+
return null;
60+
}
61+
62+
$originalType = $templateTypes[0];
63+
$filterCallback = $args[0]->value;
64+
65+
$refinedType = $this->analyzeFilterCallback($filterCallback, $originalType, $scope);
66+
67+
return new GenericObjectType(
68+
Option::class,
69+
[$refinedType]
70+
);
71+
}
72+
73+
private function analyzeFilterCallback($filterCallback, Type $originalType, Scope $scope): Type
74+
{
75+
$varName = '__psl_filter_param';
76+
$syntheticVar = new Variable($varName);
77+
78+
$scopeWithParam = $scope->assignVariable(
79+
$varName,
80+
$originalType,
81+
$originalType,
82+
TrinaryLogic::createYes()
83+
);
84+
85+
$conditionExpr = null;
86+
if ($filterCallback instanceof ArrowFunction) {
87+
$conditionExpr = $this->createConditionFromArrowFunction($filterCallback, $syntheticVar);
88+
} elseif ($filterCallback instanceof Closure) {
89+
$conditionExpr = $this->createConditionFromClosure($filterCallback, $syntheticVar);
90+
} elseif ($this->isCallableExpression($filterCallback)) {
91+
$conditionExpr = $this->createConditionFromCallable($filterCallback, $syntheticVar);
92+
}
93+
94+
if ($conditionExpr === null) {
95+
return $originalType;
96+
}
97+
98+
$specifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
99+
$scopeWithParam,
100+
$conditionExpr,
101+
TypeSpecifierContext::createTruthy()
102+
);
103+
104+
$refinedScope = $scopeWithParam->filterBySpecifiedTypes($specifiedTypes);
105+
$refinedType = $refinedScope->getVariableType($varName);
106+
107+
if (!$refinedType->equals($originalType)) {
108+
return $refinedType;
109+
}
110+
111+
return $originalType;
112+
}
113+
114+
private function createConditionFromArrowFunction(ArrowFunction $arrowFunction, Variable $syntheticVar): ?\PhpParser\Node\Expr
115+
{
116+
$params = $arrowFunction->params;
117+
if (count($params) === 0) {
118+
return null;
119+
}
120+
121+
$paramName = $params[0]->var->name ?? null;
122+
if ($paramName === null) {
123+
return null;
124+
}
125+
126+
return $this->replaceVariableInExpression($arrowFunction->expr, $paramName, $syntheticVar);
127+
}
128+
129+
private function createConditionFromClosure(Closure $closure, Variable $syntheticVar): ?\PhpParser\Node\Expr
130+
{
131+
$params = $closure->params;
132+
if (count($params) === 0) {
133+
return null;
134+
}
135+
136+
$paramName = $params[0]->var->name ?? null;
137+
if ($paramName === null) {
138+
return null;
139+
}
140+
141+
$stmts = $closure->stmts;
142+
if (count($stmts) !== 1) {
143+
return null;
144+
}
145+
146+
$stmt = $stmts[0];
147+
if (!$stmt instanceof \PhpParser\Node\Stmt\Return_) {
148+
return null;
149+
}
150+
151+
if ($stmt->expr === null) {
152+
return null;
153+
}
154+
155+
return $this->replaceVariableInExpression($stmt->expr, $paramName, $syntheticVar);
156+
}
157+
158+
private function isCallableExpression($expr): bool
159+
{
160+
return ($expr instanceof FuncCall && $expr->isFirstClassCallable())
161+
|| $expr instanceof MethodCall;
162+
}
163+
164+
private function createConditionFromCallable($callableExpr, Variable $syntheticVar): ?\PhpParser\Node\Expr
165+
{
166+
if ($callableExpr instanceof FuncCall && $callableExpr->isFirstClassCallable()) {
167+
$clonedFuncCall = clone $callableExpr;
168+
$clonedFuncCall->args = [new Arg($syntheticVar)];
169+
$clonedFuncCall->setAttribute('isFirstClassCallable', false);
170+
return $clonedFuncCall;
171+
}
172+
173+
if ($callableExpr instanceof MethodCall) {
174+
$clonedMethodCall = clone $callableExpr;
175+
176+
// Check if this is a first-class callable by looking for VariadicPlaceholder in args
177+
$isFirstClassCallable = false;
178+
if (count($clonedMethodCall->args) === 1) {
179+
$arg = $clonedMethodCall->args[0];
180+
// Check if the argument itself is a VariadicPlaceholder
181+
if ($arg instanceof \PhpParser\Node\VariadicPlaceholder) {
182+
$isFirstClassCallable = true;
183+
}
184+
// Or check if the argument value is a VariadicPlaceholder
185+
elseif (isset($arg->value) && $arg->value instanceof \PhpParser\Node\VariadicPlaceholder) {
186+
$isFirstClassCallable = true;
187+
}
188+
}
189+
190+
if ($isFirstClassCallable) {
191+
// For first-class callables like ->matches(...), replace ... with the synthetic variable
192+
$clonedMethodCall->args = [new Arg($syntheticVar)];
193+
} else {
194+
// For regular method calls, preserve existing arguments and add synthetic variable as first argument
195+
$existingArgs = $clonedMethodCall->args;
196+
$clonedMethodCall->args = [new Arg($syntheticVar), ...$existingArgs];
197+
}
198+
199+
return $clonedMethodCall;
200+
}
201+
202+
return null;
203+
}
204+
205+
private function replaceVariableInExpression(\PhpParser\Node\Expr $expr, string $paramName, Variable $replacement): \PhpParser\Node\Expr
206+
{
207+
$clonedExpr = clone $expr;
208+
$this->replaceVariableRecursive($clonedExpr, $paramName, $replacement);
209+
return $clonedExpr;
210+
}
211+
212+
private function replaceVariableRecursive(\PhpParser\Node $node, string $paramName, Variable $replacement): void
213+
{
214+
if ($node instanceof Variable && $node->name === $paramName) {
215+
$node->name = $replacement->name;
216+
return;
217+
}
218+
219+
foreach ($node->getSubNodeNames() as $subNodeName) {
220+
$subNode = $node->$subNodeName;
221+
222+
if ($subNode instanceof \PhpParser\Node) {
223+
$this->replaceVariableRecursive($subNode, $paramName, $replacement);
224+
} elseif (is_array($subNode)) {
225+
foreach ($subNode as $item) {
226+
if ($item instanceof \PhpParser\Node) {
227+
$this->replaceVariableRecursive($item, $paramName, $replacement);
228+
}
229+
}
230+
}
231+
}
232+
}
233+
}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
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+
9+
class PslTypeSpecifyingExtensionTest extends TypeInferenceTestCase
10+
{
11+
12+
/**
13+
* @return iterable<mixed>
14+
*/
15+
public function dataFileAsserts(): iterable
16+
{
17+
yield from $this->gatherAssertTypes(__DIR__ . '/data/filter.php');
18+
}
19+
20+
/**
21+
* @dataProvider dataFileAsserts
22+
* @param mixed ...$args
23+
*/
24+
public function testFileAsserts(
25+
string $assertType,
26+
string $file,
27+
...$args
28+
): void
29+
{
30+
$this->assertFileAsserts($assertType, $file, ...$args);
31+
}
32+
33+
/***
34+
* @return string[]
35+
*/
36+
public static function getAdditionalConfigFiles(): array
37+
{
38+
return [__DIR__ . '/../../extension.neon'];
39+
}
40+
41+
}

tests/Option/data/filter.php

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

0 commit comments

Comments
 (0)