diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 677aa31845..915705f56c 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -2090,6 +2090,18 @@ private function processStmtNode( $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); if ($var instanceof ArrayDimFetch && $var->dim !== null) { + $varType = $scope->getType($var->var); + if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { + $throwPoints = array_merge($throwPoints, $this->processExprNode( + $stmt, + new MethodCall($this->deepNodeCloner->cloneNode($var->var), 'offsetUnset'), + $scope, + $storage, + new NoopNodeCallback(), + ExpressionContext::createDeep(), + )->getThrowPoints()); + } + $clonedVar = $this->deepNodeCloner->cloneNode($var->var); $traverser = new NodeTraverser(); $traverser->addVisitor(new class () extends NodeVisitorAbstract { @@ -3612,6 +3624,18 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $scope = $result->getScope(); + + $varType = $scope->getType($expr->var); + if (!$varType->isArray()->yes() && !(new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { + $throwPoints = array_merge($throwPoints, $this->processExprNode( + $stmt, + new MethodCall($this->deepNodeCloner->cloneNode($expr->var), 'offsetGet'), + $scope, + $storage, + new NoopNodeCallback(), + ExpressionContext::createDeep(), + )->getThrowPoints()); + } } elseif ($expr instanceof Array_) { $itemNodes = []; $hasYield = false; @@ -3909,6 +3933,24 @@ function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $context, $sto $impurePoints = array_merge($impurePoints, $result->getImpurePoints()); $isAlwaysTerminating = $isAlwaysTerminating || $result->isAlwaysTerminating(); $nonNullabilityResults[] = $nonNullabilityResult; + + if (!($var instanceof ArrayDimFetch)) { + continue; + } + + $varType = $scope->getType($var->var); + if ($varType->isArray()->yes() || (new ObjectType(ArrayAccess::class))->isSuperTypeOf($varType)->no()) { + continue; + } + + $throwPoints = array_merge($throwPoints, $this->processExprNode( + $stmt, + new MethodCall($this->deepNodeCloner->cloneNode($var->var), 'offsetExists'), + $scope, + $storage, + new NoopNodeCallback(), + ExpressionContext::createDeep(), + )->getThrowPoints()); } foreach (array_reverse($expr->vars) as $var) { $scope = $this->lookForUnsetAllowedUndefinedExpressions($scope, $var); diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index 95a0faf039..c3a1cc2c76 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -614,6 +614,11 @@ public function testBug9267(): void $this->analyse([__DIR__ . '/data/bug-9267.php'], []); } + public function testBug11427(): void + { + $this->analyse([__DIR__ . '/data/bug-11427.php'], []); + } + #[RequiresPhp('>= 8.4')] public function testPropertyHooks(): void { diff --git a/tests/PHPStan/Rules/Exceptions/data/bug-11427.php b/tests/PHPStan/Rules/Exceptions/data/bug-11427.php new file mode 100644 index 0000000000..279a2f448d --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/bug-11427.php @@ -0,0 +1,85 @@ + */ +class C implements \ArrayAccess { + #[\ReturnTypeWillChange] + public function offsetExists($offset) { + throw new \Exception("exists"); + } + + #[\ReturnTypeWillChange] + public function offsetGet($offset) { + throw new \Exception("get"); + } + + #[\ReturnTypeWillChange] + public function offsetSet($offset, $value) { + throw new \Exception("set"); + } + + #[\ReturnTypeWillChange] + public function offsetUnset($offset) { + throw new \Exception("unset"); + } +} + +function test(C $c): void { + try { + $x = isset($c[1]); + } catch (\Exception $e) { + // offsetExists can throw + } + + try { + $x = $c[1]; + } catch (\Exception $e) { + // offsetGet can throw + } + + try { + $c[1] = 1; + } catch (\Exception $e) { + // offsetSet can throw + } + + try { + unset($c[1]); + } catch (\Exception $e) { + // offsetUnset can throw + } +} + +/** + * Union type where isArray() returns maybe and isSuperTypeOf(ArrayAccess) returns maybe. + * This ensures the conditions in NodeScopeResolver are tested with types + * that distinguish !->yes() from ->no() and !->no() from ->yes(). + * + * @param array|C $c + */ +function testArrayOrArrayAccess($c): void { + try { + $x = isset($c[1]); + } catch (\Exception $e) { + // offsetExists can throw when $c is C + } + + try { + $x = $c[1]; + } catch (\Exception $e) { + // offsetGet can throw when $c is C + } + + try { + $c[1] = 1; + } catch (\Exception $e) { + // offsetSet can throw when $c is C + } + + try { + unset($c[1]); + } catch (\Exception $e) { + // offsetUnset can throw when $c is C + } +}