Skip to content

Commit bfdbb89

Browse files
authored
Arrow function support for immediately called callable arguments (#160)
1 parent c0ae8d9 commit bfdbb89

File tree

7 files changed

+186
-4
lines changed

7 files changed

+186
-4
lines changed

src/Extension/ImmediatelyCalledCallableThrowTypeExtension.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace ShipMonk\PHPStan\Extension;
44

55
use LogicException;
6+
use PhpParser\Node\Expr\ArrowFunction;
67
use PhpParser\Node\Expr\CallLike;
78
use PhpParser\Node\Expr\Closure;
89
use PhpParser\Node\Expr\FuncCall;
@@ -150,6 +151,20 @@ static function (): void {
150151
}
151152
}
152153

154+
if ($argumentValue instanceof ArrowFunction) {
155+
$result = $this->nodeScopeResolver->processStmtNodes(
156+
$call,
157+
$argumentValue->getStmts(),
158+
$scope->enterArrowFunction($argumentValue),
159+
static function (): void {
160+
},
161+
);
162+
163+
foreach ($result->getThrowPoints() as $throwPoint) {
164+
$throwTypes[] = $throwPoint->getType();
165+
}
166+
}
167+
153168
if ($argumentValue instanceof StaticCall
154169
&& $argumentValue->isFirstClassCallable()
155170
&& $argumentValue->name instanceof Identifier

src/Rule/ForbidCheckedExceptionInCallableRule.php

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,16 @@
55
use LogicException;
66
use PhpParser\Node;
77
use PhpParser\Node\Expr;
8+
use PhpParser\Node\Expr\ArrowFunction;
89
use PhpParser\Node\Expr\CallLike;
910
use PhpParser\Node\Expr\FuncCall;
1011
use PhpParser\Node\Expr\MethodCall;
1112
use PhpParser\Node\Expr\StaticCall;
1213
use PhpParser\Node\Identifier;
1314
use PhpParser\Node\Name;
15+
use PHPStan\Analyser\ExpressionContext;
16+
use PHPStan\Analyser\MutatingScope;
17+
use PHPStan\Analyser\NodeScopeResolver;
1418
use PHPStan\Analyser\Scope;
1519
use PHPStan\Node\ClosureReturnStatementsNode;
1620
use PHPStan\Node\FunctionCallableNode;
@@ -36,6 +40,8 @@
3640
class ForbidCheckedExceptionInCallableRule implements Rule
3741
{
3842

43+
private NodeScopeResolver $nodeScopeResolver;
44+
3945
private ReflectionProvider $reflectionProvider;
4046

4147
private DefaultExceptionTypeResolver $exceptionTypeResolver;
@@ -54,6 +60,7 @@ class ForbidCheckedExceptionInCallableRule implements Rule
5460
* @param array<string, int|list<int>> $allowedCheckedExceptionCallables
5561
*/
5662
public function __construct(
63+
NodeScopeResolver $nodeScopeResolver,
5764
ReflectionProvider $reflectionProvider,
5865
DefaultExceptionTypeResolver $exceptionTypeResolver,
5966
array $immediatelyCalledCallables,
@@ -71,6 +78,7 @@ function ($argumentIndexes): array {
7178
);
7279
$this->exceptionTypeResolver = $exceptionTypeResolver;
7380
$this->reflectionProvider = $reflectionProvider;
81+
$this->nodeScopeResolver = $nodeScopeResolver;
7482
}
7583

7684
public function getNodeType(): string
@@ -98,6 +106,10 @@ public function processNode(
98106
return $this->processClosure($node, $scope);
99107
}
100108

109+
if ($node instanceof ArrowFunction) {
110+
return $this->processArrowFunction($node, $scope);
111+
}
112+
101113
return [];
102114
}
103115

@@ -180,6 +192,49 @@ public function processClosure(
180192
return $errors;
181193
}
182194

195+
/**
196+
* @return list<RuleError>
197+
*/
198+
public function processArrowFunction(
199+
ArrowFunction $node,
200+
Scope $scope
201+
): array
202+
{
203+
if (!$scope instanceof MutatingScope) { // @phpstan-ignore-line ignore BC promise
204+
throw new LogicException('Unexpected scope implementation');
205+
}
206+
207+
if ($this->isAllowedToThrowCheckedException($node, $scope)) {
208+
return [];
209+
}
210+
211+
$result = $this->nodeScopeResolver->processExprNode( // @phpstan-ignore-line ignore BC promise
212+
$node->expr,
213+
$scope->enterArrowFunction($node),
214+
static function (): void {
215+
},
216+
ExpressionContext::createDeep(), // @phpstan-ignore-line ignore BC promise
217+
);
218+
219+
$errors = [];
220+
221+
foreach ($result->getThrowPoints() as $throwPoint) { // @phpstan-ignore-line ignore BC promise
222+
if (!$throwPoint->isExplicit()) {
223+
continue;
224+
}
225+
226+
foreach ($throwPoint->getType()->getObjectClassNames() as $exceptionClass) {
227+
if ($this->exceptionTypeResolver->isCheckedException($exceptionClass, $throwPoint->getScope())) {
228+
$errors[] = RuleErrorBuilder::message("Throwing checked exception $exceptionClass in arrow function!")
229+
->line($throwPoint->getNode()->getLine())
230+
->build();
231+
}
232+
}
233+
}
234+
235+
return $errors;
236+
}
237+
183238
/**
184239
* @return list<RuleError>
185240
*/

src/Visitor/ImmediatelyCalledCallableVisitor.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace ShipMonk\PHPStan\Visitor;
44

55
use PhpParser\Node;
6+
use PhpParser\Node\Expr\ArrowFunction;
67
use PhpParser\Node\Expr\CallLike;
78
use PhpParser\Node\Expr\Closure;
89
use PhpParser\Node\Expr\FuncCall;
@@ -102,7 +103,7 @@ private function resolveMethodCall(CallLike $node): void
102103
continue;
103104
}
104105

105-
if (!$this->isFirstClassCallableOrClosure($argument->value)) {
106+
if (!$this->isFirstClassCallableOrClosureOrArrowFunction($argument->value)) {
106107
continue;
107108
}
108109

@@ -115,7 +116,7 @@ private function resolveMethodCall(CallLike $node): void
115116

116117
private function resolveFuncCall(FuncCall $node): void
117118
{
118-
if ($this->isFirstClassCallableOrClosure($node->name)) {
119+
if ($this->isFirstClassCallableOrClosureOrArrowFunction($node->name)) {
119120
// phpcs:ignore Squiz.PHP.CommentedOutCode.Found
120121
$node->name->setAttribute(self::CALLABLE_ALLOWING_CHECKED_EXCEPTION, true); // immediately called closure syntax, e.g. (function(){})()
121122
return;
@@ -139,17 +140,18 @@ private function resolveFuncCall(FuncCall $node): void
139140
continue;
140141
}
141142

142-
if (!$this->isFirstClassCallableOrClosure($argument->value)) {
143+
if (!$this->isFirstClassCallableOrClosureOrArrowFunction($argument->value)) {
143144
continue;
144145
}
145146

146147
$node->getArgs()[$argumentIndex]->value->setAttribute(self::CALLABLE_ALLOWING_CHECKED_EXCEPTION, true);
147148
}
148149
}
149150

150-
private function isFirstClassCallableOrClosure(Node $node): bool
151+
private function isFirstClassCallableOrClosureOrArrowFunction(Node $node): bool
151152
{
152153
return $node instanceof Closure
154+
|| $node instanceof ArrowFunction
153155
|| ($node instanceof MethodCall && $node->isFirstClassCallable())
154156
|| ($node instanceof NullsafeMethodCall && $node->isFirstClassCallable())
155157
|| ($node instanceof StaticCall && $node->isFirstClassCallable())

tests/Extension/data/ImmediatelyCalledCallableThrowTypeExtension/code.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,24 @@ public function testClosureWithoutThrow(): void
8080
}
8181
}
8282

83+
public function testArrowFunction(): void
84+
{
85+
try {
86+
$result = Immediate::method(fn () => throw new \Exception());
87+
} finally {
88+
assertVariableCertainty(TrinaryLogic::createMaybe(), $result);
89+
}
90+
}
91+
92+
public function testArrowFunctionWithoutThrow(): void
93+
{
94+
try {
95+
$result = Immediate::method(fn () => 42);
96+
} finally {
97+
assertVariableCertainty(TrinaryLogic::createYes(), $result);
98+
}
99+
}
100+
83101
public function testFirstClassCallable(): void
84102
{
85103
try {
@@ -181,6 +199,24 @@ public function testClosureWithoutThrow(): void
181199
}
182200
}
183201

202+
public function testArrowFunction(): void
203+
{
204+
try {
205+
$result = array_map(fn () => throw new \Exception(), []);
206+
} finally {
207+
assertVariableCertainty(TrinaryLogic::createMaybe(), $result);
208+
}
209+
}
210+
211+
public function testArrowFunctionWithoutThrow(): void
212+
{
213+
try {
214+
$result = array_map(fn () => 42, []);
215+
} finally {
216+
assertVariableCertainty(TrinaryLogic::createYes(), $result);
217+
}
218+
}
219+
184220
public function testFirstClassCallable(): void
185221
{
186222
try {

tests/Rule/ForbidCheckedExceptionInCallableRuleTest.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use LogicException;
66
use Nette\Neon\Neon;
7+
use PHPStan\Analyser\NodeScopeResolver;
78
use PHPStan\Reflection\ReflectionProvider;
89
use PHPStan\Rules\Exceptions\DefaultExceptionTypeResolver;
910
use PHPStan\Rules\Rule;
@@ -35,6 +36,7 @@ protected function getRule(): Rule
3536
$visitorConfig = Neon::decodeFile(self::getVisitorConfigFilePath());
3637

3738
return new ForbidCheckedExceptionInCallableRule(
39+
self::getContainer()->getByType(NodeScopeResolver::class),
3840
self::getContainer()->getByType(ReflectionProvider::class),
3941
new DefaultExceptionTypeResolver( // @phpstan-ignore-line ignore BC promise
4042
self::getContainer()->getByType(ReflectionProvider::class),

tests/Rule/data/ForbidCheckedExceptionInCallableRule/code.php

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,3 +224,73 @@ public function allowThrow(callable $callable): void
224224
}
225225

226226
}
227+
228+
class ArrowFunctionTest extends BaseCallableTest {
229+
230+
public function testDeclarations(): void
231+
{
232+
$fn = fn () => throw new CheckedException(); // error: Throwing checked exception ForbidCheckedExceptionInCallableRule\CheckedException in arrow function!
233+
234+
$fn2 = fn () => $this->throws(); // error: Throwing checked exception ForbidCheckedExceptionInCallableRule\CheckedException in arrow function!
235+
236+
$fn3 = fn () => $this->noop(); // implicit throw is ignored
237+
238+
$fn4 = fn (callable $c) => $c(); // implicit throw is ignored (https://github.com/phpstan/phpstan/issues/9779)
239+
}
240+
241+
public function testExplicitExecution(): void
242+
{
243+
(fn () => throw new CheckedException())();
244+
}
245+
246+
public function testPassedCallbacks(): void
247+
{
248+
$this->immediateThrow(fn () => throw new CheckedException());
249+
250+
array_map(fn () => throw new CheckedException(), []);
251+
252+
array_map(fn () => $this->throws(), []);
253+
254+
$this->allowThrow(fn () => $this->throws());
255+
256+
$this->allowThrowInBaseClass(fn () => $this->throws());
257+
258+
$this->allowThrowInInterface(fn () => $this->throws());
259+
260+
$this->denied(fn () => throw new CheckedException()); // error: Throwing checked exception ForbidCheckedExceptionInCallableRule\CheckedException in arrow function!
261+
262+
$this?->denied(fn () => throw new CheckedException()); // error: Throwing checked exception ForbidCheckedExceptionInCallableRule\CheckedException in arrow function!
263+
}
264+
265+
private function noop(): void
266+
{
267+
}
268+
269+
/**
270+
* @throws CheckedException
271+
*/
272+
private function throws(): void
273+
{
274+
throw new CheckedException();
275+
}
276+
277+
private function denied(callable $callable): void
278+
{
279+
280+
}
281+
282+
public function immediateThrow(callable $callable): void
283+
{
284+
$callable();
285+
}
286+
287+
public function allowThrow(callable $callable): void
288+
{
289+
try {
290+
$callable();
291+
} catch (\Exception $e) {
292+
293+
}
294+
}
295+
296+
}

tests/Rule/data/ForbidCheckedExceptionInCallableRule/visitor.neon

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@ services:
66
'array_map': 0
77
'ForbidCheckedExceptionInCallableRule\ClosureTest::immediateThrow': 0
88
'ForbidCheckedExceptionInCallableRule\FirstClassCallableTest::immediateThrow': 1
9+
'ForbidCheckedExceptionInCallableRule\ArrowFunctionTest::immediateThrow': 0
910
allowedCheckedExceptionCallables:
1011
'ForbidCheckedExceptionInCallableRule\CallableTest::allowThrowInInterface': [0]
1112
'ForbidCheckedExceptionInCallableRule\BaseCallableTest::allowThrowInBaseClass': [0]
1213
'ForbidCheckedExceptionInCallableRule\ClosureTest::allowThrow': [0]
1314
'ForbidCheckedExceptionInCallableRule\FirstClassCallableTest::allowThrow': [1]
15+
'ForbidCheckedExceptionInCallableRule\ArrowFunctionTest::allowThrow': [0]
1416
tags:
1517
- phpstan.parser.richParserNodeVisitor

0 commit comments

Comments
 (0)