diff --git a/composer.json b/composer.json index 5d167be2ea0..9f5025779bf 100644 --- a/composer.json +++ b/composer.json @@ -26,7 +26,7 @@ "hoa/compiler": "3.17.08.08", "hoa/exception": "^1.0", "hoa/file": "1.17.07.11", - "jetbrains/phpstorm-stubs": "dev-master#709e512210784a7c0a677b3a89d35def844a59b9", + "jetbrains/phpstorm-stubs": "dev-master#e4f5f6c3de39f3bab3e9f3fca4b8cdb8b061e681", "nette/bootstrap": "^3.0", "nette/di": "^3.1.10", "nette/neon": "3.3.4", diff --git a/composer.lock b/composer.lock index 32703b39428..008a399526d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "db2851f465e721f4d9359ca5171e9ac5", + "content-hash": "1695e0a01d8cab6ccd93819aa3179bff", "packages": [ { "name": "clue/ndjson-react", @@ -1637,19 +1637,19 @@ "source": { "type": "git", "url": "https://github.com/JetBrains/phpstorm-stubs", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9" + "reference": "e4f5f6c3de39f3bab3e9f3fca4b8cdb8b061e681" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/709e512210784a7c0a677b3a89d35def844a59b9", - "reference": "709e512210784a7c0a677b3a89d35def844a59b9", + "url": "https://api.github.com/repos/JetBrains/phpstorm-stubs/zipball/e4f5f6c3de39f3bab3e9f3fca4b8cdb8b061e681", + "reference": "e4f5f6c3de39f3bab3e9f3fca4b8cdb8b061e681", "shasum": "" }, "require-dev": { "friendsofphp/php-cs-fixer": "^v3.86", "nikic/php-parser": "^v5.6", - "phpdocumentor/reflection-docblock": "^5.6", - "phpunit/phpunit": "^12.3" + "phpdocumentor/reflection-docblock": "^6.0", + "phpunit/phpunit": "^13.2" }, "default-branch": true, "type": "library", @@ -1674,7 +1674,7 @@ "stubs", "type" ], - "time": "2026-06-12T13:19:10+00:00" + "time": "2026-09-07T18:07:02+00:00" }, { "name": "nette/bootstrap", diff --git a/resources/functionMetadata.php b/resources/functionMetadata.php index addae7ff100..794d9ce6c3f 100644 --- a/resources/functionMetadata.php +++ b/resources/functionMetadata.php @@ -1105,6 +1105,7 @@ 'grapheme_stristr' => ['hasSideEffects' => false], 'grapheme_strlen' => ['hasSideEffects' => false], 'grapheme_strpos' => ['hasSideEffects' => false], + 'grapheme_strrev' => ['hasSideEffects' => false], 'grapheme_strripos' => ['hasSideEffects' => false], 'grapheme_strrpos' => ['hasSideEffects' => false], 'grapheme_strstr' => ['hasSideEffects' => false], @@ -1324,6 +1325,8 @@ 'locale_filter_matches' => ['hasSideEffects' => false], 'locale_get_all_variants' => ['hasSideEffects' => false], 'locale_get_default' => ['hasSideEffects' => false], + 'locale_get_display_keyword' => ['hasSideEffects' => false], + 'locale_get_display_keyword_value' => ['hasSideEffects' => false], 'locale_get_display_language' => ['hasSideEffects' => false], 'locale_get_display_name' => ['hasSideEffects' => false], 'locale_get_display_region' => ['hasSideEffects' => false], diff --git a/src/Analyser/StmtHandler/ExpressionHandler.php b/src/Analyser/StmtHandler/ExpressionHandler.php index 9db8b02f087..010f8715627 100644 --- a/src/Analyser/StmtHandler/ExpressionHandler.php +++ b/src/Analyser/StmtHandler/ExpressionHandler.php @@ -2,6 +2,7 @@ namespace PHPStan\Analyser\StmtHandler; +use Error; use PhpParser\Node; use PhpParser\Node\Expr; use PhpParser\Node\Stmt; @@ -22,6 +23,7 @@ use PHPStan\Node\PropertyAssignNode; use PHPStan\Node\VariableAssignNode; use PHPStan\Type\NeverType; +use PHPStan\Type\ObjectType; use function array_filter; use function count; @@ -73,7 +75,11 @@ public function processStmt( } $nodeScopeResolver->callNodeCallback($nodeCallback, $stmt, $entryScope, $storage); - $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit()); + // Errors signal programmer mistakes (ValueError, TypeError, DivisionByZeroError...), + // nobody calls an otherwise pure expression just to have them thrown, so they + // do not make the expression statement meaningful. + $errorType = new ObjectType(Error::class); + $throwPoints = array_filter($result->getThrowPoints(), static fn ($throwPoint) => $throwPoint->isExplicit() && !$errorType->isSuperTypeOf($throwPoint->getType())->yes()); if ( count($result->getImpurePoints()) === 0 && count($throwPoints) === 0 diff --git a/src/Type/Php/ReflectionClassConstantConstructorThrowTypeExtension.php b/src/Type/Php/ReflectionClassConstantConstructorThrowTypeExtension.php new file mode 100644 index 00000000000..2e805decf82 --- /dev/null +++ b/src/Type/Php/ReflectionClassConstantConstructorThrowTypeExtension.php @@ -0,0 +1,69 @@ +getName() === '__construct' && $methodReflection->getDeclaringClass()->getName() === ReflectionClassConstant::class; + } + + public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type + { + if (count($methodCall->getArgs()) < 2) { + return $methodReflection->getThrowType(); + } + + $valueType = $scope->getType($methodCall->getArgs()[0]->value); + $constantType = $scope->getType($methodCall->getArgs()[1]->value); + foreach ($valueType->getConstantStrings() as $constantString) { + if (!$this->reflectionProvider->hasClass($constantString->getValue())) { + return $methodReflection->getThrowType(); + } + + $classReflection = $this->reflectionProvider->getClass($constantString->getValue()); + foreach ($constantType->getConstantStrings() as $constantConstantString) { + if (!$classReflection->hasConstant($constantConstantString->getValue())) { + return $methodReflection->getThrowType(); + } + } + + $valueType = TypeCombinator::remove($valueType, $constantString); + } + + if (!$valueType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + // Look for non constantStrings value. + foreach ($constantType->getConstantStrings() as $constantConstantString) { + $constantType = TypeCombinator::remove($constantType, $constantConstantString); + } + + if (!$constantType instanceof NeverType) { + return $methodReflection->getThrowType(); + } + + return null; + } + +} diff --git a/src/Type/Php/TriggerErrorFunctionThrowTypeExtension.php b/src/Type/Php/TriggerErrorFunctionThrowTypeExtension.php new file mode 100644 index 00000000000..49c61fc2642 --- /dev/null +++ b/src/Type/Php/TriggerErrorFunctionThrowTypeExtension.php @@ -0,0 +1,109 @@ +getName() === 'trigger_error'; + } + + public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $funcCall, Scope $scope): ?Type + { + $args = $funcCall->getArgs(); + if (count($args) === 0) { + return $functionReflection->getThrowType(); + } + + $errorHandlerThrowType = $this->implicitThrows ? new ObjectType(Throwable::class) : null; + if (count($args) === 1) { + return $errorHandlerThrowType; + } + + $errorLevels = $scope->getType($args[1]->value)->getConstantScalarValues(); + if (count($errorLevels) === 0) { + if ($errorHandlerThrowType !== null) { + return $errorHandlerThrowType; + } + + return $this->getInvalidErrorLevelThrowType(); + } + + $throwTypes = []; + foreach ($errorLevels as $errorLevel) { + if ($errorLevel === E_USER_ERROR) { + $throwTypes[] = new ObjectType(Throwable::class); + continue; + } + + if (is_int($errorLevel) && in_array($errorLevel, self::NON_FATAL_ERROR_LEVELS, true)) { + if ($errorHandlerThrowType !== null) { + $throwTypes[] = $errorHandlerThrowType; + } + continue; + } + + $invalidErrorLevelThrowType = $this->getInvalidErrorLevelThrowType(); + if ($invalidErrorLevelThrowType === null) { + continue; + } + + $throwTypes[] = $invalidErrorLevelThrowType; + } + + if (count($throwTypes) === 0) { + return null; + } + + return TypeCombinator::union(...$throwTypes); + } + + private function getInvalidErrorLevelThrowType(): ?Type + { + if (!$this->phpVersion->throwsValueErrorForInternalFunctions()) { + return null; + } + + return new ObjectType(ValueError::class); + } + +} diff --git a/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php b/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php index 164e981fc96..e79cc53a5db 100644 --- a/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php +++ b/tests/PHPStan/Rules/DeadCode/NoopRuleTest.php @@ -140,6 +140,25 @@ public function testRule(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testErrorThrows(): void + { + $this->analyse([__DIR__ . '/data/noop-error-throws.php'], [ + [ + 'Expression "$a / $b" on a separate line does not do anything.', + 6, + ], + [ + 'Expression "$a % $b" on a separate line does not do anything.', + 7, + ], + [ + 'Expression "match ($a) {…" on a separate line does not do anything.', + 8, + ], + ]); + } + public function testNullsafe(): void { $this->analyse([__DIR__ . '/data/nullsafe-property-fetch-noop.php'], [ diff --git a/tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php b/tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php new file mode 100644 index 00000000000..3bd7e790000 --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/noop-error-throws.php @@ -0,0 +1,12 @@ += 8.0 + +namespace DeadCodeNoopErrorThrows; + +function (int $a, int $b, string $s) { + $a / $b; + $a % $b; + match ($a) { + 1 => 'a', + }; + new \DateTimeImmutable($s); +}; diff --git a/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php b/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php index 7c7bda75143..cbf67ffed2c 100644 --- a/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php +++ b/tests/PHPStan/Rules/Exceptions/AbilityToDisableImplicitThrowsTest.php @@ -97,6 +97,29 @@ public function testBug7799(): void ]); } + #[RequiresPhp('>= 8.0.0')] + public function testTriggerErrorThrowType(): void + { + $this->analyse([__DIR__ . '/data/trigger-error-throw-type.php'], [ + [ + 'Dead catch - Exception is never thrown in the try block.', + 14, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 19, + ], + [ + 'Dead catch - ValueError is never thrown in the try block.', + 34, + ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 39, + ], + ]); + } + public static function getAdditionalConfigFiles(): array { return array_merge( diff --git a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php index d77785142b5..248c5d0b219 100644 --- a/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php +++ b/tests/PHPStan/Rules/Exceptions/CatchWithUnthrownExceptionRuleTest.php @@ -141,6 +141,10 @@ public function testRule(): void 'Dead catch - ArithmeticError is never thrown in the try block.', 762, ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 802, + ], ]); } @@ -233,6 +237,10 @@ public function testRuleWithoutReportingUncheckedException(): void 'Dead catch - Exception is never thrown in the try block.', 555, ], + [ + 'Dead catch - Exception is never thrown in the try block.', + 802, + ], ]); } @@ -842,4 +850,15 @@ public function testBug9826(): void $this->analyse([__DIR__ . '/data/bug-9826.php'], []); } + #[RequiresPhp('>= 8.0.0')] + public function testTriggerErrorThrowType(): void + { + $this->analyse([__DIR__ . '/data/trigger-error-throw-type.php'], [ + [ + 'Dead catch - Exception is never thrown in the try block.', + 39, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Exceptions/data/trigger-error-throw-type.php b/tests/PHPStan/Rules/Exceptions/data/trigger-error-throw-type.php new file mode 100644 index 00000000000..a50cd9a6ff3 --- /dev/null +++ b/tests/PHPStan/Rules/Exceptions/data/trigger-error-throw-type.php @@ -0,0 +1,42 @@ += 8.0 + +namespace TriggerErrorThrowType; + +function doFoo(int $level): void +{ + try { + trigger_error('foo', E_USER_ERROR); + } catch (\Exception $e) { + + } + try { + trigger_error('foo', E_USER_DEPRECATED); + } catch (\Exception $e) { + + } + try { + trigger_error('foo'); + } catch (\Exception $e) { + + } + try { + trigger_error('foo', 12345); + } catch (\ValueError $e) { + + } + try { + trigger_error('foo', $level); + } catch (\ValueError $e) { + + } + try { + trigger_error('foo', E_USER_WARNING); + } catch (\ValueError $e) { + + } + try { + trigger_error('foo', 12345); + } catch (\Exception $e) { + + } +} diff --git a/tests/PHPStan/Rules/Exceptions/data/unthrown-exception.php b/tests/PHPStan/Rules/Exceptions/data/unthrown-exception.php index 0327fcb086b..07e5b65ee5c 100644 --- a/tests/PHPStan/Rules/Exceptions/data/unthrown-exception.php +++ b/tests/PHPStan/Rules/Exceptions/data/unthrown-exception.php @@ -790,3 +790,51 @@ public function doFoo(int $int, int $negativeInt, int $positiveInt): void } } + +class TestReflectionClassConstant +{ + public const FOO = 1; + + public function doFoo(): void + { + try { + new \ReflectionClassConstant(self::class, 'FOO'); + } catch (\Exception $e) { + + } + } + + public function doBar(): void + { + try { + new \ReflectionClassConstant(self::class, 'BAR'); + } catch (\Exception $e) { + + } + try { + new \ReflectionClassConstant(\DateTime::class, 'BAR'); + } catch (\Exception $e) { + + } + } + + public function doBaz(string $string): void + { + try { + new \ReflectionClassConstant($string, $string); + } catch (\Exception $e) { + + } + try { + new \ReflectionClassConstant(self::class, $string); + } catch (\Exception $e) { + + } + try { + new \ReflectionClassConstant($string, 'FOO'); + } catch (\Exception $e) { + + } + } + +} diff --git a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php index 1e02b374418..f81bbcf3291 100644 --- a/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Functions/CallToFunctionStatementWithoutSideEffectsRuleTest.php @@ -113,6 +113,32 @@ public function testBug11101(): void ]); } + public function testErrorThrows(): void + { + $this->analyse([__DIR__ . '/data/function-call-statement-no-side-effects-error-throws.php'], [ + [ + 'Call to function sprintf() on a separate line has no effect.', + 22, + ], + [ + 'Call to function strpos() on a separate line has no effect.', + 23, + ], + [ + 'Call to function intdiv() on a separate line has no effect.', + 24, + ], + [ + 'Call to function array_combine() on a separate line has no effect.', + 25, + ], + [ + 'Call to function FunctionCallStatementNoSideEffectsErrorThrows\\pureAndThrowsError() on a separate line has no effect.', + 27, + ], + ]); + } + public function testBug4455(): void { require_once __DIR__ . '/data/bug-4455.php'; diff --git a/tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-error-throws.php b/tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-error-throws.php new file mode 100644 index 00000000000..236770dfdfa --- /dev/null +++ b/tests/PHPStan/Rules/Functions/data/function-call-statement-no-side-effects-error-throws.php @@ -0,0 +1,31 @@ += 8.0.0')] public function testRule(): void - { - $this->analyse([__DIR__ . '/data/method-call-statement-no-side-effects.php'], [ - [ - 'Call to method DateTimeImmutable::modify() on a separate line has no effect.', - 15, - ], - [ - 'Call to method Exception::getCode() on a separate line has no effect.', - 21, - ], - [ - 'Call to method MethodCallStatementNoSideEffects\Bar::doPure() on a separate line has no effect.', - 63, - ], - [ - 'Call to method MethodCallStatementNoSideEffects\Bar::doPureWithThrowsVoid() on a separate line has no effect.', - 64, - ], - ]); - } - - #[RequiresPhp('< 8.0.0')] - public function testRulePhp7(): void { $this->analyse([__DIR__ . '/data/method-call-statement-no-side-effects.php'], [ [ diff --git a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php index 4f3696de3ec..d9310f5048a 100644 --- a/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php +++ b/tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php @@ -6,7 +6,6 @@ use PHPStan\Rules\RuleLevelHelper; use PHPStan\Testing\RuleTestCase; use PHPUnit\Framework\Attributes\RequiresPhp; -use const PHP_VERSION_ID; /** * @extends RuleTestCase @@ -32,19 +31,7 @@ protected function getRule(): Rule ); } - #[RequiresPhp('>= 8.0.0')] public function testRule(): void - { - $this->analyse([__DIR__ . '/data/static-method-call-statement-no-side-effects.php'], [ - [ - 'Call to method DateTime::format() on a separate line has no effect.', - 23, - ], - ]); - } - - #[RequiresPhp('< 8.0.0')] - public function testRulePhp7(): void { $this->analyse([__DIR__ . '/data/static-method-call-statement-no-side-effects.php'], [ [ @@ -122,16 +109,12 @@ public function testFirstClassCallables(): void public function testBug10819(): void { - $errors = []; - if (PHP_VERSION_ID < 80000) { - $errors = [ - [ - 'Call to static method DateTime::createFromFormat() on a separate line has no effect.', - 13, - ], - ]; - } - $this->analyse([__DIR__ . '/data/bug-10819.php'], $errors); + $this->analyse([__DIR__ . '/data/bug-10819.php'], [ + [ + 'Call to static method DateTime::createFromFormat() on a separate line has no effect.', + 13, + ], + ]); } public function testDynamicStaticCall(): void