Skip to content

Commit e8c314c

Browse files
ondrejmirtesclaude
andcommitted
Model trigger_error() throw type through the error handler
PhpStorm stubs now declare @throws \ValueError on trigger_error(), which replaced the never-returning "may throw Throwable" treatment of E_USER_ERROR and the implicit throw point of the other levels. Keep both, and report ValueError only for an invalid error level on PHP 8. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W2hMwFhyMX7JHn6oVKrTPD
1 parent c574aa2 commit e8c314c

3 files changed

Lines changed: 174 additions & 0 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Type\Php;
4+
5+
use PhpParser\Node\Expr\FuncCall;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\DependencyInjection\AutowiredParameter;
8+
use PHPStan\DependencyInjection\AutowiredService;
9+
use PHPStan\Php\PhpVersion;
10+
use PHPStan\Reflection\FunctionReflection;
11+
use PHPStan\Type\DynamicFunctionThrowTypeExtension;
12+
use PHPStan\Type\ObjectType;
13+
use PHPStan\Type\Type;
14+
use PHPStan\Type\TypeCombinator;
15+
use Throwable;
16+
use ValueError;
17+
use function count;
18+
use function in_array;
19+
use function is_int;
20+
use const E_USER_DEPRECATED;
21+
use const E_USER_ERROR;
22+
use const E_USER_NOTICE;
23+
use const E_USER_WARNING;
24+
25+
/**
26+
* trigger_error() itself throws only ValueError for an invalid error level, but the
27+
* registered error handler may throw anything. E_USER_ERROR terminates the script
28+
* unless the handler throws, so the call is modelled as throwing Throwable there,
29+
* the same way never-returning calls are.
30+
*/
31+
#[AutowiredService]
32+
final class TriggerErrorFunctionThrowTypeExtension implements DynamicFunctionThrowTypeExtension
33+
{
34+
35+
private const NON_FATAL_ERROR_LEVELS = [E_USER_WARNING, E_USER_NOTICE, E_USER_DEPRECATED];
36+
37+
public function __construct(
38+
private PhpVersion $phpVersion,
39+
#[AutowiredParameter(ref: '%exceptions.implicitThrows%')]
40+
private bool $implicitThrows,
41+
)
42+
{
43+
}
44+
45+
public function isFunctionSupported(FunctionReflection $functionReflection): bool
46+
{
47+
return $functionReflection->getName() === 'trigger_error';
48+
}
49+
50+
public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): ?Type
51+
{
52+
$args = $funcCall->getArgs();
53+
if (count($args) === 0) {
54+
return $functionReflection->getThrowType();
55+
}
56+
57+
$errorHandlerThrowType = $this->implicitThrows ? new ObjectType(Throwable::class) : null;
58+
if (count($args) === 1) {
59+
return $errorHandlerThrowType;
60+
}
61+
62+
$errorLevels = $scope->getType($args[1]->value)->getConstantScalarValues();
63+
if (count($errorLevels) === 0) {
64+
if ($errorHandlerThrowType !== null) {
65+
return $errorHandlerThrowType;
66+
}
67+
68+
return $this->getInvalidErrorLevelThrowType();
69+
}
70+
71+
$throwTypes = [];
72+
foreach ($errorLevels as $errorLevel) {
73+
if ($errorLevel === E_USER_ERROR) {
74+
$throwTypes[] = new ObjectType(Throwable::class);
75+
continue;
76+
}
77+
78+
if (is_int($errorLevel) && in_array($errorLevel, self::NON_FATAL_ERROR_LEVELS, true)) {
79+
if ($errorHandlerThrowType !== null) {
80+
$throwTypes[] = $errorHandlerThrowType;
81+
}
82+
continue;
83+
}
84+
85+
$invalidErrorLevelThrowType = $this->getInvalidErrorLevelThrowType();
86+
if ($invalidErrorLevelThrowType === null) {
87+
continue;
88+
}
89+
90+
$throwTypes[] = $invalidErrorLevelThrowType;
91+
}
92+
93+
if (count($throwTypes) === 0) {
94+
return null;
95+
}
96+
97+
return TypeCombinator::union(...$throwTypes);
98+
}
99+
100+
private function getInvalidErrorLevelThrowType(): ?Type
101+
{
102+
if (!$this->phpVersion->throwsValueErrorForInternalFunctions()) {
103+
return null;
104+
}
105+
106+
return new ObjectType(ValueError::class);
107+
}
108+
109+
}

‎tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,29 @@ public function testBug7799(): void
9797
]);
9898
}
9999

100+
#[RequiresPhp('>= 8.0.0')]
101+
public function testTriggerErrorThrowType(): void
102+
{
103+
$this->analyse([__DIR__ . '/data/trigger-error-throw-type.php'], [
104+
[
105+
'Dead catch - Exception is never thrown in the try block.',
106+
14,
107+
],
108+
[
109+
'Dead catch - Exception is never thrown in the try block.',
110+
19,
111+
],
112+
[
113+
'Dead catch - ValueError is never thrown in the try block.',
114+
34,
115+
],
116+
[
117+
'Dead catch - Exception is never thrown in the try block.',
118+
39,
119+
],
120+
]);
121+
}
122+
100123
public static function getAdditionalConfigFiles(): array
101124
{
102125
return array_merge(
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
<?php // lint >= 8.0
2+
3+
namespace TriggerErrorThrowType;
4+
5+
function doFoo(int $level): void
6+
{
7+
try {
8+
trigger_error('foo', E_USER_ERROR);
9+
} catch (\Exception $e) {
10+
11+
}
12+
try {
13+
trigger_error('foo', E_USER_DEPRECATED);
14+
} catch (\Exception $e) {
15+
16+
}
17+
try {
18+
trigger_error('foo');
19+
} catch (\Exception $e) {
20+
21+
}
22+
try {
23+
trigger_error('foo', 12345);
24+
} catch (\ValueError $e) {
25+
26+
}
27+
try {
28+
trigger_error('foo', $level);
29+
} catch (\ValueError $e) {
30+
31+
}
32+
try {
33+
trigger_error('foo', E_USER_WARNING);
34+
} catch (\ValueError $e) {
35+
36+
}
37+
try {
38+
trigger_error('foo', 12345);
39+
} catch (\Exception $e) {
40+
41+
}
42+
}

0 commit comments

Comments
 (0)