Skip to content

Commit

Permalink
Arrow function support for immediately called callable arguments (#160)
Browse files Browse the repository at this point in the history
  • Loading branch information
xificurk committed Sep 15, 2023
1 parent c0ae8d9 commit bfdbb89
Show file tree
Hide file tree
Showing 7 changed files with 186 additions and 4 deletions.
15 changes: 15 additions & 0 deletions src/Extension/ImmediatelyCalledCallableThrowTypeExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ShipMonk\PHPStan\Extension;

use LogicException;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\FuncCall;
Expand Down Expand Up @@ -150,6 +151,20 @@ static function (): void {
}
}

if ($argumentValue instanceof ArrowFunction) {
$result = $this->nodeScopeResolver->processStmtNodes(
$call,
$argumentValue->getStmts(),
$scope->enterArrowFunction($argumentValue),
static function (): void {
},
);

foreach ($result->getThrowPoints() as $throwPoint) {
$throwTypes[] = $throwPoint->getType();
}
}

if ($argumentValue instanceof StaticCall
&& $argumentValue->isFirstClassCallable()
&& $argumentValue->name instanceof Identifier
Expand Down
55 changes: 55 additions & 0 deletions src/Rule/ForbidCheckedExceptionInCallableRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@
use LogicException;
use PhpParser\Node;
use PhpParser\Node\Expr;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\FuncCall;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name;
use PHPStan\Analyser\ExpressionContext;
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\Analyser\Scope;
use PHPStan\Node\ClosureReturnStatementsNode;
use PHPStan\Node\FunctionCallableNode;
Expand All @@ -36,6 +40,8 @@
class ForbidCheckedExceptionInCallableRule implements Rule
{

private NodeScopeResolver $nodeScopeResolver;

private ReflectionProvider $reflectionProvider;

private DefaultExceptionTypeResolver $exceptionTypeResolver;
Expand All @@ -54,6 +60,7 @@ class ForbidCheckedExceptionInCallableRule implements Rule
* @param array<string, int|list<int>> $allowedCheckedExceptionCallables
*/
public function __construct(
NodeScopeResolver $nodeScopeResolver,
ReflectionProvider $reflectionProvider,
DefaultExceptionTypeResolver $exceptionTypeResolver,
array $immediatelyCalledCallables,
Expand All @@ -71,6 +78,7 @@ function ($argumentIndexes): array {
);
$this->exceptionTypeResolver = $exceptionTypeResolver;
$this->reflectionProvider = $reflectionProvider;
$this->nodeScopeResolver = $nodeScopeResolver;
}

public function getNodeType(): string
Expand Down Expand Up @@ -98,6 +106,10 @@ public function processNode(
return $this->processClosure($node, $scope);
}

if ($node instanceof ArrowFunction) {
return $this->processArrowFunction($node, $scope);
}

return [];
}

Expand Down Expand Up @@ -180,6 +192,49 @@ public function processClosure(
return $errors;
}

/**
* @return list<RuleError>
*/
public function processArrowFunction(
ArrowFunction $node,
Scope $scope
): array
{
if (!$scope instanceof MutatingScope) { // @phpstan-ignore-line ignore BC promise
throw new LogicException('Unexpected scope implementation');
}

if ($this->isAllowedToThrowCheckedException($node, $scope)) {
return [];
}

$result = $this->nodeScopeResolver->processExprNode( // @phpstan-ignore-line ignore BC promise
$node->expr,
$scope->enterArrowFunction($node),
static function (): void {
},
ExpressionContext::createDeep(), // @phpstan-ignore-line ignore BC promise
);

$errors = [];

foreach ($result->getThrowPoints() as $throwPoint) { // @phpstan-ignore-line ignore BC promise
if (!$throwPoint->isExplicit()) {
continue;
}

foreach ($throwPoint->getType()->getObjectClassNames() as $exceptionClass) {
if ($this->exceptionTypeResolver->isCheckedException($exceptionClass, $throwPoint->getScope())) {
$errors[] = RuleErrorBuilder::message("Throwing checked exception $exceptionClass in arrow function!")
->line($throwPoint->getNode()->getLine())
->build();
}
}
}

return $errors;
}

/**
* @return list<RuleError>
*/
Expand Down
10 changes: 6 additions & 4 deletions src/Visitor/ImmediatelyCalledCallableVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace ShipMonk\PHPStan\Visitor;

use PhpParser\Node;
use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Expr\CallLike;
use PhpParser\Node\Expr\Closure;
use PhpParser\Node\Expr\FuncCall;
Expand Down Expand Up @@ -102,7 +103,7 @@ private function resolveMethodCall(CallLike $node): void
continue;
}

if (!$this->isFirstClassCallableOrClosure($argument->value)) {
if (!$this->isFirstClassCallableOrClosureOrArrowFunction($argument->value)) {
continue;
}

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

private function resolveFuncCall(FuncCall $node): void
{
if ($this->isFirstClassCallableOrClosure($node->name)) {
if ($this->isFirstClassCallableOrClosureOrArrowFunction($node->name)) {
// phpcs:ignore Squiz.PHP.CommentedOutCode.Found
$node->name->setAttribute(self::CALLABLE_ALLOWING_CHECKED_EXCEPTION, true); // immediately called closure syntax, e.g. (function(){})()
return;
Expand All @@ -139,17 +140,18 @@ private function resolveFuncCall(FuncCall $node): void
continue;
}

if (!$this->isFirstClassCallableOrClosure($argument->value)) {
if (!$this->isFirstClassCallableOrClosureOrArrowFunction($argument->value)) {
continue;
}

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

private function isFirstClassCallableOrClosure(Node $node): bool
private function isFirstClassCallableOrClosureOrArrowFunction(Node $node): bool
{
return $node instanceof Closure
|| $node instanceof ArrowFunction
|| ($node instanceof MethodCall && $node->isFirstClassCallable())
|| ($node instanceof NullsafeMethodCall && $node->isFirstClassCallable())
|| ($node instanceof StaticCall && $node->isFirstClassCallable())
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,24 @@ public function testClosureWithoutThrow(): void
}
}

public function testArrowFunction(): void
{
try {
$result = Immediate::method(fn () => throw new \Exception());
} finally {
assertVariableCertainty(TrinaryLogic::createMaybe(), $result);
}
}

public function testArrowFunctionWithoutThrow(): void
{
try {
$result = Immediate::method(fn () => 42);
} finally {
assertVariableCertainty(TrinaryLogic::createYes(), $result);
}
}

public function testFirstClassCallable(): void
{
try {
Expand Down Expand Up @@ -181,6 +199,24 @@ public function testClosureWithoutThrow(): void
}
}

public function testArrowFunction(): void
{
try {
$result = array_map(fn () => throw new \Exception(), []);
} finally {
assertVariableCertainty(TrinaryLogic::createMaybe(), $result);
}
}

public function testArrowFunctionWithoutThrow(): void
{
try {
$result = array_map(fn () => 42, []);
} finally {
assertVariableCertainty(TrinaryLogic::createYes(), $result);
}
}

public function testFirstClassCallable(): void
{
try {
Expand Down
2 changes: 2 additions & 0 deletions tests/Rule/ForbidCheckedExceptionInCallableRuleTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use LogicException;
use Nette\Neon\Neon;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Exceptions\DefaultExceptionTypeResolver;
use PHPStan\Rules\Rule;
Expand Down Expand Up @@ -35,6 +36,7 @@ protected function getRule(): Rule
$visitorConfig = Neon::decodeFile(self::getVisitorConfigFilePath());

return new ForbidCheckedExceptionInCallableRule(
self::getContainer()->getByType(NodeScopeResolver::class),
self::getContainer()->getByType(ReflectionProvider::class),
new DefaultExceptionTypeResolver( // @phpstan-ignore-line ignore BC promise
self::getContainer()->getByType(ReflectionProvider::class),
Expand Down
70 changes: 70 additions & 0 deletions tests/Rule/data/ForbidCheckedExceptionInCallableRule/code.php
Original file line number Diff line number Diff line change
Expand Up @@ -224,3 +224,73 @@ public function allowThrow(callable $callable): void
}

}

class ArrowFunctionTest extends BaseCallableTest {

public function testDeclarations(): void
{
$fn = fn () => throw new CheckedException(); // error: Throwing checked exception ForbidCheckedExceptionInCallableRule\CheckedException in arrow function!

$fn2 = fn () => $this->throws(); // error: Throwing checked exception ForbidCheckedExceptionInCallableRule\CheckedException in arrow function!

$fn3 = fn () => $this->noop(); // implicit throw is ignored

$fn4 = fn (callable $c) => $c(); // implicit throw is ignored (https://github.com/phpstan/phpstan/issues/9779)
}

public function testExplicitExecution(): void
{
(fn () => throw new CheckedException())();
}

public function testPassedCallbacks(): void
{
$this->immediateThrow(fn () => throw new CheckedException());

array_map(fn () => throw new CheckedException(), []);

array_map(fn () => $this->throws(), []);

$this->allowThrow(fn () => $this->throws());

$this->allowThrowInBaseClass(fn () => $this->throws());

$this->allowThrowInInterface(fn () => $this->throws());

$this->denied(fn () => throw new CheckedException()); // error: Throwing checked exception ForbidCheckedExceptionInCallableRule\CheckedException in arrow function!

$this?->denied(fn () => throw new CheckedException()); // error: Throwing checked exception ForbidCheckedExceptionInCallableRule\CheckedException in arrow function!
}

private function noop(): void
{
}

/**
* @throws CheckedException
*/
private function throws(): void
{
throw new CheckedException();
}

private function denied(callable $callable): void
{

}

public function immediateThrow(callable $callable): void
{
$callable();
}

public function allowThrow(callable $callable): void
{
try {
$callable();
} catch (\Exception $e) {

}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@ services:
'array_map': 0
'ForbidCheckedExceptionInCallableRule\ClosureTest::immediateThrow': 0
'ForbidCheckedExceptionInCallableRule\FirstClassCallableTest::immediateThrow': 1
'ForbidCheckedExceptionInCallableRule\ArrowFunctionTest::immediateThrow': 0
allowedCheckedExceptionCallables:
'ForbidCheckedExceptionInCallableRule\CallableTest::allowThrowInInterface': [0]
'ForbidCheckedExceptionInCallableRule\BaseCallableTest::allowThrowInBaseClass': [0]
'ForbidCheckedExceptionInCallableRule\ClosureTest::allowThrow': [0]
'ForbidCheckedExceptionInCallableRule\FirstClassCallableTest::allowThrow': [1]
'ForbidCheckedExceptionInCallableRule\ArrowFunctionTest::allowThrow': [0]
tags:
- phpstan.parser.richParserNodeVisitor

0 comments on commit bfdbb89

Please sign in to comment.