From 963ae225bc3511753d28590d4de08ad71cfc6c9d Mon Sep 17 00:00:00 2001 From: Jan Nedbal Date: Tue, 7 Apr 2026 12:21:52 +0200 Subject: [PATCH] ForbidCheckedExceptionInYieldingMethod: skip closures handled by callable rule Extract shared ImmediatelyInvokedCallableHelper to eliminate duplication between the callable and yielding rules. The yielding rule now only reports closures that are @param-immediately-invoked-callable or directly invoked, since non-immediately-invoked and standalone closures are already covered by ForbidCheckedExceptionInCallableRule. Closes #346 Co-Authored-By: Claude Code --- rules.neon | 2 + src/Helper/CallAnalysisResult.php | 48 +++++ .../ImmediatelyInvokedCallableHelper.php | 204 ++++++++++++++++++ .../ForbidCheckedExceptionInCallableRule.php | 150 ++----------- ...idCheckedExceptionInYieldingMethodRule.php | 70 +++++- ...rbidCheckedExceptionInCallableRuleTest.php | 8 +- ...eckedExceptionInYieldingMethodRuleTest.php | 9 +- .../code.php | 50 +++++ 8 files changed, 399 insertions(+), 142 deletions(-) create mode 100644 src/Helper/CallAnalysisResult.php create mode 100644 src/Helper/ImmediatelyInvokedCallableHelper.php diff --git a/rules.neon b/rules.neon index c23929d..befbbab 100644 --- a/rules.neon +++ b/rules.neon @@ -303,6 +303,8 @@ conditionalTags: phpstan.parser.richParserNodeVisitor: %shipmonkRules.uselessPrivatePropertyDefaultValue.enabled% services: + - + class: ShipMonk\PHPStan\Helper\ImmediatelyInvokedCallableHelper - class: ShipMonk\PHPStan\Rule\AllowComparingOnlyComparableTypesRule - diff --git a/src/Helper/CallAnalysisResult.php b/src/Helper/CallAnalysisResult.php new file mode 100644 index 0000000..2ae5840 --- /dev/null +++ b/src/Helper/CallAnalysisResult.php @@ -0,0 +1,48 @@ + + */ + public array $reorderedArguments; + + /** + * @var array + */ + public array $immediatelyInvokedHashes; + + /** + * @param FunctionReflection|MethodReflection $reflection + * @param list $reorderedArguments + * @param array $immediatelyInvokedHashes + */ + public function __construct( + object $reflection, + ?Type $callerType, + array $reorderedArguments, + array $immediatelyInvokedHashes + ) + { + $this->reflection = $reflection; + $this->callerType = $callerType; + $this->reorderedArguments = $reorderedArguments; + $this->immediatelyInvokedHashes = $immediatelyInvokedHashes; + } + +} diff --git a/src/Helper/ImmediatelyInvokedCallableHelper.php b/src/Helper/ImmediatelyInvokedCallableHelper.php new file mode 100644 index 0000000..e217235 --- /dev/null +++ b/src/Helper/ImmediatelyInvokedCallableHelper.php @@ -0,0 +1,204 @@ +reflectionProvider = $reflectionProvider; + } + + /** + * Returns spl_object_hashes of callable argument nodes that are + * param-immediately-invoked-callable or directly invoked. + * + * @return array + */ + public function getImmediatelyInvokedHashes( + CallLike $node, + Scope $scope + ): array + { + // Directly invoked callable syntax: (function(){...})(), (fn() => ...)() + if ($node instanceof FuncCall && $this->isCallableExpression($node->name)) { + return [spl_object_hash($node->name) => true]; + } + + $analysis = $this->analyzeCall($node, $scope); + + return $analysis !== null ? $analysis->immediatelyInvokedHashes : []; + } + + /** + * Full analysis of a call — returns the resolved reflection, caller type, + * reordered arguments, and which argument hashes are immediately invoked. + * + * Returns null for calls that cannot be resolved (e.g. dynamic method names). + */ + public function analyzeCall( + CallLike $node, + Scope $scope + ): ?CallAnalysisResult + { + if ($node instanceof MethodCall && $node->name instanceof Identifier) { + $callerType = $scope->getType($node->var); + $methodReflection = $scope->getMethodReflection($callerType, $node->name->name); + + } elseif ($node instanceof StaticCall && $node->name instanceof Identifier && $node->class instanceof Name) { + $callerType = $scope->resolveTypeByName($node->class); + $methodReflection = $scope->getMethodReflection($callerType, $node->name->name); + + } elseif ($node instanceof New_ && $node->class instanceof Name) { + $callerType = $scope->resolveTypeByName($node->class); + $methodReflection = $scope->getMethodReflection($callerType, '__construct'); + + } elseif ($node instanceof FuncCall && $node->name instanceof Name) { + $callerType = null; + $methodReflection = $this->getFunctionReflection($node->name, $scope); + + } else { + return null; + } + + if ($methodReflection === null) { + return null; + } + + $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( + $scope, + $node->getArgs(), + $methodReflection->getVariants(), + $methodReflection->getNamedArgumentsVariants(), + ); + + if ($node instanceof New_) { + $arguments = (ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $node) ?? $node)->getArgs(); + + } elseif ($node instanceof FuncCall) { + $arguments = (ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node) ?? $node)->getArgs(); + + } elseif ($node instanceof MethodCall) { + $arguments = (ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $node) ?? $node)->getArgs(); + + } else { + $arguments = (ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $node) ?? $node)->getArgs(); + } + + /** @var list $args */ + $args = array_values($arguments); + $parameters = $parametersAcceptor->getParameters(); + + $immediatelyInvokedHashes = []; + + foreach ($args as $index => $arg) { + $parameterIndex = $this->getParameterIndex($arg, $index, $parameters) ?? -1; + $parameter = $parameters[$parameterIndex] ?? null; + + if ($this->isImmediatelyInvokedCallable($methodReflection, $parameter)) { + $immediatelyInvokedHashes[spl_object_hash($arg->value)] = true; + } + } + + return new CallAnalysisResult( + $methodReflection, + $callerType, + $args, + $immediatelyInvokedHashes, + ); + } + + public function isCallableExpression(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()) + || ($node instanceof FuncCall && $node->isFirstClassCallable()); + } + + /** + * Copied from phpstan + * + * @param FunctionReflection|MethodReflection $reflection + * + * @see https://github.com/phpstan/phpstan-src/commit/cefa296f24b8c0b7d4dc3d383cbceea35267cb3f + */ + private function isImmediatelyInvokedCallable( + object $reflection, + ?ParameterReflection $parameter + ): bool + { + if ($parameter instanceof ExtendedParameterReflection) { + $parameterCallImmediately = $parameter->isImmediatelyInvokedCallable(); + + if ($parameterCallImmediately->maybe()) { + return $reflection instanceof FunctionReflection; + } + + return $parameterCallImmediately->yes(); + } + + return $reflection instanceof FunctionReflection; + } + + /** + * @param array $parameters + */ + private function getParameterIndex( + Arg $arg, + int $argumentIndex, + array $parameters + ): ?int + { + if ($arg->name === null) { + return $argumentIndex; + } + + foreach ($parameters as $parameterIndex => $parameter) { + if ($parameter->getName() === $arg->name->toString()) { + return $parameterIndex; + } + } + + return null; + } + + private function getFunctionReflection( + Name $functionName, + Scope $scope + ): ?FunctionReflection + { + return $this->reflectionProvider->hasFunction($functionName, $scope) + ? $this->reflectionProvider->getFunction($functionName, $scope) + : null; + } + +} diff --git a/src/Rule/ForbidCheckedExceptionInCallableRule.php b/src/Rule/ForbidCheckedExceptionInCallableRule.php index 407c9d4..3f407b2 100644 --- a/src/Rule/ForbidCheckedExceptionInCallableRule.php +++ b/src/Rule/ForbidCheckedExceptionInCallableRule.php @@ -5,20 +5,15 @@ use Generator; use LogicException; use PhpParser\Node; -use PhpParser\Node\Arg; use PhpParser\Node\Expr\ArrowFunction; use PhpParser\Node\Expr\CallLike; -use PhpParser\Node\Expr\Closure; use PhpParser\Node\Expr\FuncCall; use PhpParser\Node\Expr\MethodCall; -use PhpParser\Node\Expr\New_; -use PhpParser\Node\Expr\NullsafeMethodCall; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Identifier; use PhpParser\Node\Name; use PhpParser\Node\Stmt\Expression; use PhpParser\Node\Stmt\Namespace_; -use PHPStan\Analyser\ArgumentsNormalizer; use PHPStan\Analyser\MutatingScope; use PHPStan\Analyser\NodeScopeResolver; use PHPStan\Analyser\Scope; @@ -29,20 +24,15 @@ use PHPStan\Node\FunctionCallableNode; use PHPStan\Node\MethodCallableNode; use PHPStan\Node\StaticMethodCallableNode; -use PHPStan\Reflection\ExtendedParameterReflection; -use PHPStan\Reflection\FunctionReflection; -use PHPStan\Reflection\MethodReflection; -use PHPStan\Reflection\ParameterReflection; -use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\Exceptions\DefaultExceptionTypeResolver; use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; use PHPStan\Type\Type; +use ShipMonk\PHPStan\Helper\ImmediatelyInvokedCallableHelper; use function array_map; use function array_shift; -use function array_values; use function explode; use function in_array; use function is_int; @@ -61,6 +51,8 @@ class ForbidCheckedExceptionInCallableRule implements Rule private DefaultExceptionTypeResolver $exceptionTypeResolver; + private ImmediatelyInvokedCallableHelper $callableHelper; + /** * @var array spl_hash => true */ @@ -96,7 +88,8 @@ public function __construct( NodeScopeResolver $nodeScopeResolver, ReflectionProvider $reflectionProvider, DefaultExceptionTypeResolver $exceptionTypeResolver, - array $allowedCheckedExceptionCallables + array $allowedCheckedExceptionCallables, + ImmediatelyInvokedCallableHelper $callableHelper ) { $this->checkClassExistence($reflectionProvider, $allowedCheckedExceptionCallables); @@ -110,6 +103,7 @@ function ($argumentIndexes): array { $this->exceptionTypeResolver = $exceptionTypeResolver; $this->reflectionProvider = $reflectionProvider; $this->nodeScopeResolver = $nodeScopeResolver; + $this->callableHelper = $callableHelper; } public function getNodeType(): string @@ -355,29 +349,6 @@ private function checkClassExistence( } } - /** - * Copied from phpstan https://github.com/phpstan/phpstan-src/commit/cefa296f24b8c0b7d4dc3d383cbceea35267cb3f#diff-0c3f50d118357d9cb6d6f4d0eade75b83797d57056ff3b9c58ec881a13eaa6feR4113 - * - * @param FunctionReflection|MethodReflection $reflection - */ - private function isImmediatelyInvokedCallable( - object $reflection, - ?ParameterReflection $parameter - ): bool - { - if ($parameter instanceof ExtendedParameterReflection) { - $parameterCallImmediately = $parameter->isImmediatelyInvokedCallable(); - - if ($parameterCallImmediately->maybe()) { - return $reflection instanceof FunctionReflection; - } - - return $parameterCallImmediately->yes(); - } - - return $reflection instanceof FunctionReflection; - } - private function isAllowedCheckedExceptionCallable( ?Type $caller, string $calledMethodName, @@ -427,113 +398,36 @@ private function whitelistAllowedCallables( Scope $scope ): void { - if ($node instanceof MethodCall && $node->name instanceof Identifier) { - $callerType = $scope->getType($node->var); - $methodReflection = $scope->getMethodReflection($callerType, $node->name->name); - - } elseif ($node instanceof StaticCall && $node->name instanceof Identifier && $node->class instanceof Name) { - $callerType = $scope->resolveTypeByName($node->class); - $methodReflection = $scope->getMethodReflection($callerType, $node->name->name); - - } elseif ($node instanceof New_ && $node->class instanceof Name) { - $callerType = $scope->resolveTypeByName($node->class); - $methodReflection = $scope->getMethodReflection($callerType, '__construct'); - - } elseif ($node instanceof FuncCall && $node->name instanceof Name) { - $callerType = null; - $methodReflection = $this->getFunctionReflection($node->name, $scope); - - } elseif ($node instanceof FuncCall && $this->isFirstClassCallableOrClosureOrArrowFunction($node->name)) { // immediately called callable syntax + // Directly invoked callable syntax: (function(){...})(), (fn() => ...)() + if ($node instanceof FuncCall && $this->callableHelper->isCallableExpression($node->name)) { $this->allowedCallables[spl_object_hash($node->name)] = true; return; - - } else { - return; } - if ($methodReflection === null) { - return; - } - - $parametersAcceptor = ParametersAcceptorSelector::selectFromArgs( - $scope, - $node->getArgs(), - $methodReflection->getVariants(), - $methodReflection->getNamedArgumentsVariants(), - ); - - if ($node instanceof New_) { - $arguments = (ArgumentsNormalizer::reorderNewArguments($parametersAcceptor, $node) ?? $node)->getArgs(); - - } elseif ($node instanceof FuncCall) { - $arguments = (ArgumentsNormalizer::reorderFuncArguments($parametersAcceptor, $node) ?? $node)->getArgs(); - - } elseif ($node instanceof MethodCall) { - $arguments = (ArgumentsNormalizer::reorderMethodArguments($parametersAcceptor, $node) ?? $node)->getArgs(); + $analysis = $this->callableHelper->analyzeCall($node, $scope); - } elseif ($node instanceof StaticCall) { - $arguments = (ArgumentsNormalizer::reorderStaticCallArguments($parametersAcceptor, $node) ?? $node)->getArgs(); - - } else { - throw new LogicException('Unexpected node type'); + if ($analysis === null) { + return; } - /** @var list $args */ - $args = array_values($arguments); - $parameters = $parametersAcceptor->getParameters(); - - foreach ($args as $index => $arg) { - $parameterIndex = $this->getParameterIndex($arg, $index, $parameters) ?? -1; - $parameter = $parameters[$parameterIndex] ?? null; + foreach ($analysis->reorderedArguments as $index => $arg) { $argHash = spl_object_hash($arg->value); if ( - $this->isImmediatelyInvokedCallable($methodReflection, $parameter) - || $this->isAllowedCheckedExceptionCallable($callerType, $methodReflection->getName(), $index) + isset($analysis->immediatelyInvokedHashes[$argHash]) + || $this->isAllowedCheckedExceptionCallable($analysis->callerType, $analysis->reflection->getName(), $index) ) { $this->allowedCallables[$argHash] = true; } - if ($this->isFirstClassCallableOrClosureOrArrowFunction($arg->value)) { - $callerClass = $callerType !== null && $callerType->getObjectClassNames() !== [] ? $callerType->getObjectClassNames()[0] : null; - $methodReference = $callerClass !== null ? "$callerClass::{$methodReflection->getName()}" : $methodReflection->getName(); + if ($this->callableHelper->isCallableExpression($arg->value)) { + $callerClass = $analysis->callerType !== null && $analysis->callerType->getObjectClassNames() !== [] ? $analysis->callerType->getObjectClassNames()[0] : null; + $methodReference = $callerClass !== null ? "$callerClass::{$analysis->reflection->getName()}" : $analysis->reflection->getName(); $this->callablesInArguments[$argHash] = $methodReference; } } } - /** - * @param array $parameters - */ - private function getParameterIndex( - Arg $arg, - int $argumentIndex, - array $parameters - ): ?int - { - if ($arg->name === null) { - return $argumentIndex; - } - - foreach ($parameters as $parameterIndex => $parameter) { - if ($parameter->getName() === $arg->name->toString()) { - return $parameterIndex; - } - } - - return null; - } - - 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()) - || ($node instanceof FuncCall && $node->isFirstClassCallable()); - } - private function buildError( string $exceptionClass, string $where, @@ -557,14 +451,4 @@ private function buildError( return $builder->build(); } - private function getFunctionReflection( - Name $functionName, - Scope $scope - ): ?FunctionReflection - { - return $this->reflectionProvider->hasFunction($functionName, $scope) - ? $this->reflectionProvider->getFunction($functionName, $scope) - : null; - } - } diff --git a/src/Rule/ForbidCheckedExceptionInYieldingMethodRule.php b/src/Rule/ForbidCheckedExceptionInYieldingMethodRule.php index 914f1ad..32975e9 100644 --- a/src/Rule/ForbidCheckedExceptionInYieldingMethodRule.php +++ b/src/Rule/ForbidCheckedExceptionInYieldingMethodRule.php @@ -3,8 +3,11 @@ namespace ShipMonk\PHPStan\Rule; use PhpParser\Node; +use PhpParser\Node\Expr\CallLike; use PHPStan\Analyser\Scope; +use PHPStan\Node\ClassMethodsNode; use PHPStan\Node\ClosureReturnStatementsNode; +use PHPStan\Node\FileNode; use PHPStan\Node\FunctionReturnStatementsNode; use PHPStan\Node\PropertyHookReturnStatementsNode; use PHPStan\Node\ReturnStatementsNode; @@ -12,27 +15,44 @@ use PHPStan\Rules\IdentifierRuleError; use PHPStan\Rules\Rule; use PHPStan\Rules\RuleErrorBuilder; +use ShipMonk\PHPStan\Helper\ImmediatelyInvokedCallableHelper; +use function spl_object_hash; /** - * @implements Rule + * @implements Rule */ class ForbidCheckedExceptionInYieldingMethodRule implements Rule { private ExceptionTypeResolver $exceptionTypeResolver; - public function __construct(ExceptionTypeResolver $exceptionTypeResolver) + private ImmediatelyInvokedCallableHelper $callableHelper; + + /** + * @var array + */ + private array $immediatelyInvokedClosures = []; + + /** + * @var list + */ + private array $pendingClosures = []; + + public function __construct( + ExceptionTypeResolver $exceptionTypeResolver, + ImmediatelyInvokedCallableHelper $callableHelper + ) { $this->exceptionTypeResolver = $exceptionTypeResolver; + $this->callableHelper = $callableHelper; } public function getNodeType(): string { - return ReturnStatementsNode::class; + return Node::class; } /** - * @param ReturnStatementsNode $node * @return list */ public function processNode( @@ -40,10 +60,48 @@ public function processNode( Scope $scope ): array { - if (!$node->getStatementResult()->hasYield()) { - return []; + $errors = []; + + if ($node instanceof FileNode) { + $this->immediatelyInvokedClosures = []; + $this->pendingClosures = []; + + } elseif ($node instanceof CallLike) { + $this->immediatelyInvokedClosures += $this->callableHelper->getImmediatelyInvokedHashes($node, $scope); + + } elseif ($node instanceof ClosureReturnStatementsNode) { + if ($node->getStatementResult()->hasYield()) { + $this->pendingClosures[] = $node; + } + + } elseif ($node instanceof ReturnStatementsNode) { + if ($node->getStatementResult()->hasYield()) { + $errors = $this->checkThrowPoints($node); + } } + if (!$scope->isInClass() || $node instanceof ClassMethodsNode) { + foreach ($this->pendingClosures as $closureNode) { + $closureHash = spl_object_hash($closureNode->getClosureExpr()); + + if (isset($this->immediatelyInvokedClosures[$closureHash])) { + foreach ($this->checkThrowPoints($closureNode) as $error) { + $errors[] = $error; + } + } + } + + $this->pendingClosures = []; + } + + return $errors; + } + + /** + * @return list + */ + private function checkThrowPoints(ReturnStatementsNode $node): array + { $errors = []; $functionName = $this->getFunctionName($node); diff --git a/tests/Rule/ForbidCheckedExceptionInCallableRuleTest.php b/tests/Rule/ForbidCheckedExceptionInCallableRuleTest.php index fb75f28..eb7fd8d 100644 --- a/tests/Rule/ForbidCheckedExceptionInCallableRuleTest.php +++ b/tests/Rule/ForbidCheckedExceptionInCallableRuleTest.php @@ -7,6 +7,7 @@ use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\Exceptions\DefaultExceptionTypeResolver; use PHPStan\Rules\Rule; +use ShipMonk\PHPStan\Helper\ImmediatelyInvokedCallableHelper; use ShipMonk\PHPStan\RuleTestCase; /** @@ -32,11 +33,13 @@ protected function getRule(): Rule throw new LogicException('Missing implicitThrows'); } + $reflectionProvider = self::getContainer()->getByType(ReflectionProvider::class); + return new ForbidCheckedExceptionInCallableRule( self::getContainer()->getByType(NodeScopeResolver::class), - self::getContainer()->getByType(ReflectionProvider::class), + $reflectionProvider, new DefaultExceptionTypeResolver( // @phpstan-ignore phpstanApi.constructor - self::getContainer()->getByType(ReflectionProvider::class), + $reflectionProvider, [], [], [], @@ -52,6 +55,7 @@ protected function getRule(): Rule 'ForbidCheckedExceptionInCallableRule\allowed_function' => [0], // not really needed as functions are always considered immediately invoked (https://phpstan.org/writing-php-code/phpdocs-basics#callables) 'ForbidCheckedExceptionInCallableRule\allowed_function_not_immediate' => [0], ], + new ImmediatelyInvokedCallableHelper($reflectionProvider), ); } diff --git a/tests/Rule/ForbidCheckedExceptionInYieldingMethodRuleTest.php b/tests/Rule/ForbidCheckedExceptionInYieldingMethodRuleTest.php index 4d98416..775906a 100644 --- a/tests/Rule/ForbidCheckedExceptionInYieldingMethodRuleTest.php +++ b/tests/Rule/ForbidCheckedExceptionInYieldingMethodRuleTest.php @@ -3,8 +3,10 @@ namespace ShipMonk\PHPStan\Rule; use ForbidCheckedExceptionInYieldingMethodRule\CheckedException; +use PHPStan\Reflection\ReflectionProvider; use PHPStan\Rules\Exceptions\ExceptionTypeResolver; use PHPStan\Rules\Rule; +use ShipMonk\PHPStan\Helper\ImmediatelyInvokedCallableHelper; use ShipMonk\PHPStan\RuleTestCase; use Throwable; @@ -24,7 +26,12 @@ protected function getRule(): Rule return $className === CheckedException::class || $className === Throwable::class; }); - return new ForbidCheckedExceptionInYieldingMethodRule($exceptionTypeResolverMock); + $reflectionProvider = self::getContainer()->getByType(ReflectionProvider::class); + + return new ForbidCheckedExceptionInYieldingMethodRule( + $exceptionTypeResolverMock, + new ImmediatelyInvokedCallableHelper($reflectionProvider), + ); } public function testClass(): void diff --git a/tests/Rule/data/ForbidCheckedExceptionInYieldingMethodRule/code.php b/tests/Rule/data/ForbidCheckedExceptionInYieldingMethodRule/code.php index 8eda5e7..52c0204 100644 --- a/tests/Rule/data/ForbidCheckedExceptionInYieldingMethodRule/code.php +++ b/tests/Rule/data/ForbidCheckedExceptionInYieldingMethodRule/code.php @@ -86,6 +86,40 @@ public static function testIt(bool $throw): Generator yield from self::methodWithUncheckedException($throw); } + /** + * @param Closure(): mixed $callback + */ + private function passThruNotImmediate(Closure $callback): mixed + { + return $callback(); + } + + /** + * Closure passed to method WITHOUT param-immediately-invoked-callable — no error (handled by callable rule) + */ + public function throwPointOfYieldingClosureNotImmediate(bool $throw): iterable + { + return $this->passThruNotImmediate(function () use ($throw): iterable { + yield 1; + if ($throw) { + throw new CheckedException(); + } + }); + } + + /** + * Closure passed via named argument to immediately-invoked-callable — error + */ + public function throwPointOfYieldingClosureWithNamedArg(bool $throw): iterable + { + return $this->passThru(callback: function () use ($throw): iterable { + yield 1; + if ($throw) { + throw new CheckedException(); // error: Throwing checked exception ForbidCheckedExceptionInYieldingMethodRule\CheckedException in yielding closure is denied as it gets thrown upon Generator iteration + } + }); + } + } function testFunction(): iterable { @@ -94,3 +128,19 @@ function testFunction(): iterable { throw new CheckedException(); // error: Throwing checked exception ForbidCheckedExceptionInYieldingMethodRule\CheckedException in yielding function is denied as it gets thrown upon Generator iteration } }; + +// Standalone closure assigned to variable — no error (handled by callable rule) +$standaloneClosure = function (): iterable { + yield 1; + if (random_int(0, 1)) { + throw new CheckedException(); + } +}; + +// Directly invoked closure — error (not handled by callable rule) +(function (): void { + yield 1; + if (random_int(0, 1)) { + throw new CheckedException(); // error: Throwing checked exception ForbidCheckedExceptionInYieldingMethodRule\CheckedException in yielding closure is denied as it gets thrown upon Generator iteration + } +})();