├── src ├── RuntimeException.php ├── UnsupportedClassException.php ├── UnsupportedFunctionException.php ├── Node │ └── TryCatchTryEnd.php ├── DynamicConstructorThrowTypeExtension.php ├── DynamicFunctionThrowTypeExtension.php ├── DynamicMethodThrowTypeExtension.php ├── DynamicStaticMethodThrowTypeExtension.php ├── Extension │ ├── SplFileObjectExtension.php │ ├── DateIntervalExtension.php │ ├── SimpleXMLElementExtension.php │ ├── DOMDocumentExtension.php │ ├── DateTimeExtension.php │ ├── IntdivExtension.php │ ├── JsonEncodeDecodeExtension.php │ └── ReflectionExtension.php ├── DynamicThrowTypeServiceFactory.php ├── CheckedExceptionService.php ├── Rules │ ├── DeadCatchUnionRule.php │ ├── UnreachableCatchRule.php │ ├── ThrowsScope.php │ ├── UselessThrowsPhpDocRule.php │ ├── ThrowsPhpDocInheritanceRule.php │ └── ThrowsPhpDocRule.php ├── DefaultThrowTypeExtension.php ├── DefaultThrowTypeService.php ├── ThrowsAnnotationReader.php └── DynamicThrowTypeService.php ├── .github └── workflows │ └── tests.yml ├── composer.json ├── extension.neon └── README.md /src/RuntimeException.php: -------------------------------------------------------------------------------- 1 | $node->getLine(), 15 | ]); 16 | } 17 | 18 | } 19 | -------------------------------------------------------------------------------- /src/DynamicConstructorThrowTypeExtension.php: -------------------------------------------------------------------------------- 1 | getDeclaringClass()->getName(), SplFileObject::class, true)) { 27 | return new UnionType([ 28 | new ObjectType(RuntimeException::class), 29 | new ObjectType(LogicException::class), 30 | ]); 31 | } 32 | 33 | throw new UnsupportedClassException(); 34 | } 35 | 36 | } 37 | -------------------------------------------------------------------------------- /src/DynamicThrowTypeServiceFactory.php: -------------------------------------------------------------------------------- 1 | container = $container; 23 | } 24 | 25 | public function create(): DynamicThrowTypeService 26 | { 27 | return new DynamicThrowTypeService( 28 | $this->container->getServicesByTag(self::TAG_DYNAMIC_METHOD_THROW_TYPE), 29 | $this->container->getServicesByTag(self::TAG_DYNAMIC_STATIC_METHOD_THROW_TYPE), 30 | $this->container->getServicesByTag(self::TAG_DYNAMIC_CONSTRUCTOR_THROW_TYPE), 31 | $this->container->getServicesByTag(self::TAG_DYNAMIC_FUNCTION_THROW_TYPE) 32 | ); 33 | } 34 | 35 | } 36 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: tests 2 | 3 | on: [pull_request] 4 | 5 | jobs: 6 | tests: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | matrix: 10 | php-version: 11 | - "7.1" 12 | - "7.2" 13 | - "7.3" 14 | - "7.4" 15 | - "8.0" 16 | 17 | steps: 18 | - name: "Checkout" 19 | uses: "actions/checkout@v2.0.0" 20 | 21 | - name: "Install PHP" 22 | uses: shivammathur/setup-php@v2 23 | with: 24 | coverage: "none" 25 | php-version: "${{ matrix.php-version }}" 26 | 27 | - name: "Validate Composer" 28 | run: "composer validate" 29 | 30 | - name: "Cache dependencies" 31 | uses: "actions/cache@v1.1.2" 32 | with: 33 | path: "~/.composer/cache" 34 | key: "php-${{ matrix.php-version }}-composer-${{ hashFiles('**/composer.json') }}" 35 | restore-keys: "php-${{ matrix.php-version }}-composer-" 36 | 37 | - name: "Install dependencies" 38 | run: "composer update --no-interaction --no-progress --no-suggest --prefer-dist" 39 | 40 | - name: "Check Composer" 41 | run: "composer run-script check:composer" 42 | if: ${{ matrix.php-version == '7.4' }} 43 | 44 | - name: "Check lint" 45 | run: "composer run-script check:lint" 46 | if: ${{ matrix.php-version == '8.0' }} 47 | 48 | - name: "Check CodeStyle" 49 | run: "composer run-script check:cs" 50 | if: ${{ matrix.php-version == '7.4' }} 51 | 52 | - name: "Check types" 53 | run: "composer run-script check:types" 54 | if: ${{ matrix.php-version == '7.4' }} 55 | 56 | - name: "Check tests" 57 | run: "composer run-script check:tests" 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pepakriz/phpstan-exception-rules", 3 | "description": "Exception rules for PHPStan", 4 | "type": "phpstan-extension", 5 | "license": [ 6 | "MIT" 7 | ], 8 | "prefer-stable": true, 9 | "extra": { 10 | "branch-alias": { 11 | "dev-master": "0.12-dev" 12 | }, 13 | "phpstan": { 14 | "includes": [ 15 | "extension.neon" 16 | ] 17 | } 18 | }, 19 | "require": { 20 | "php": ">=7.1", 21 | "nikic/php-parser": "^4.13", 22 | "phpstan/phpstan": "^1.0" 23 | }, 24 | "require-dev": { 25 | "nette/utils": "^3.0", 26 | "php-parallel-lint/php-console-highlighter": "^0.4.0", 27 | "php-parallel-lint/php-parallel-lint": "^1.2.0", 28 | "phpstan/phpstan-nette": "^1.0", 29 | "phpstan/phpstan-phpunit": "^1.0", 30 | "phpstan/phpstan-strict-rules": "^1.0", 31 | "phpunit/phpunit": "^7.5.6 || ^9.4.2", 32 | "slevomat/coding-standard": "^6.4.1", 33 | "squizlabs/php_codesniffer": "~3.5.2" 34 | }, 35 | "autoload": { 36 | "psr-4": { 37 | "Pepakriz\\PHPStanExceptionRules\\": "src" 38 | } 39 | }, 40 | "autoload-dev": { 41 | "psr-4": { 42 | "Pepakriz\\PHPStanExceptionRules\\": "tests/src/" 43 | }, 44 | "classmap": [ 45 | "tests/src" 46 | ] 47 | }, 48 | "scripts": { 49 | "check": [ 50 | "@check:composer", 51 | "@check:lint", 52 | "@check:cs", 53 | "@check:types", 54 | "@check:tests" 55 | ], 56 | "check:composer": "composer validate", 57 | "check:tests": "phpunit", 58 | "check:cs": "phpcs --extensions=php --encoding=utf-8 --tab-width=4 --colors --ignore=tests/*/data -sp src tests/src", 59 | "check:lint": "parallel-lint src tests/src", 60 | "check:types": "phpstan analyse --memory-limit=1G -l 8 src tests", 61 | "fix": "@fix:cs", 62 | "fix:cs": "phpcbf --extensions=php --encoding=utf-8 --tab-width=4 --colors --ignore=tests/*/data -sp src tests/src" 63 | }, 64 | "config": { 65 | "sort-packages": true 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Extension/DateIntervalExtension.php: -------------------------------------------------------------------------------- 1 | getDeclaringClass()->getName(), DateInterval::class, true)) { 30 | return $this->resolveThrowType($newNode->getArgs(), $scope); 31 | } 32 | 33 | throw new UnsupportedClassException(); 34 | } 35 | 36 | /** 37 | * @param Arg[] $args 38 | */ 39 | private function resolveThrowType(array $args, Scope $scope): Type 40 | { 41 | $exceptionType = new ObjectType(Exception::class); 42 | if (!isset($args[0])) { 43 | return $exceptionType; 44 | } 45 | 46 | $valueType = $scope->getType($args[0]->value); 47 | foreach (TypeUtils::getConstantStrings($valueType) as $constantString) { 48 | try { 49 | new DateInterval($constantString->getValue()); 50 | } catch (Exception $e) { 51 | return $exceptionType; 52 | } 53 | 54 | $valueType = TypeCombinator::remove($valueType, $constantString); 55 | } 56 | 57 | if (!$valueType instanceof NeverType) { 58 | return $exceptionType; 59 | } 60 | 61 | return new VoidType(); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/Extension/SimpleXMLElementExtension.php: -------------------------------------------------------------------------------- 1 | getDeclaringClass()->getName(), SimpleXMLElement::class, true)) { 30 | return $this->resolveThrowType($newNode->getArgs(), $scope); 31 | } 32 | 33 | throw new UnsupportedClassException(); 34 | } 35 | 36 | /** 37 | * @param Arg[] $args 38 | */ 39 | private function resolveThrowType(array $args, Scope $scope): Type 40 | { 41 | $exceptionType = new ObjectType(Exception::class); 42 | if (!isset($args[0])) { 43 | return $exceptionType; 44 | } 45 | 46 | $valueType = $scope->getType($args[0]->value); 47 | foreach (TypeUtils::getConstantStrings($valueType) as $constantString) { 48 | try { 49 | new SimpleXMLElement($constantString->getValue()); 50 | } catch (Exception $e) { 51 | return $exceptionType; 52 | } 53 | 54 | $valueType = TypeCombinator::remove($valueType, $constantString); 55 | } 56 | 57 | if (!$valueType instanceof NeverType) { 58 | return $exceptionType; 59 | } 60 | 61 | return new VoidType(); 62 | } 63 | 64 | } 65 | -------------------------------------------------------------------------------- /src/CheckedExceptionService.php: -------------------------------------------------------------------------------- 1 | 0 && $uncheckedExceptionsCounter > 0) { 35 | throw new LogicException('$checkedExceptions and $uncheckedExceptions cannot be configured at the same time'); 36 | } 37 | 38 | $this->checkedExceptions = $checkedExceptionsCounter > 0 ? $checkedExceptions : null; 39 | $this->uncheckedExceptions = $uncheckedExceptions; 40 | } 41 | 42 | /** 43 | * @param string[] $classes 44 | * @return string[] 45 | */ 46 | public function filterCheckedExceptions(array $classes): array 47 | { 48 | return array_filter($classes, function (string $class): bool { 49 | return $this->isCheckedException($class); 50 | }); 51 | } 52 | 53 | public function isCheckedException(string $exceptionClassName): bool 54 | { 55 | if ($this->checkedExceptions !== null) { 56 | foreach ($this->checkedExceptions as $checkedException) { 57 | if (is_a($exceptionClassName, $checkedException, true)) { 58 | return true; 59 | } 60 | } 61 | 62 | return false; 63 | } 64 | 65 | foreach ($this->uncheckedExceptions as $uncheckedException) { 66 | if (is_a($exceptionClassName, $uncheckedException, true)) { 67 | return false; 68 | } 69 | } 70 | 71 | return true; 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/Rules/DeadCatchUnionRule.php: -------------------------------------------------------------------------------- 1 | 18 | */ 19 | class DeadCatchUnionRule implements Rule 20 | { 21 | 22 | /** @var Broker */ 23 | private $broker; 24 | 25 | public function __construct(Broker $broker) 26 | { 27 | $this->broker = $broker; 28 | } 29 | 30 | public function getNodeType(): string 31 | { 32 | return Catch_::class; 33 | } 34 | 35 | /** 36 | * @return string[] 37 | */ 38 | public function processNode(Node $node, Scope $scope): array 39 | { 40 | /** @var Catch_ $node */ 41 | $node = $node; 42 | 43 | if (count($node->types) <= 1) { 44 | return []; 45 | } 46 | 47 | /** @var ClassReflection[] $types */ 48 | $types = []; 49 | foreach ($node->types as $type) { 50 | try { 51 | $types[] = $this->broker->getClass($type->toString()); 52 | } catch (ClassNotFoundException $exception) { 53 | // ignore, already spotted by built-in rules 54 | } 55 | } 56 | 57 | /** @var string[] $errors */ 58 | $errors = []; 59 | foreach ($types as $index => $type) { 60 | foreach ($types as $otherIndex => $otherType) { 61 | if ($index === $otherIndex) { 62 | continue; 63 | } 64 | 65 | if ($type === $otherType) { 66 | $errors[] = sprintf('Type %s is redundant', $type->getName()); 67 | 68 | continue 2; 69 | } 70 | 71 | if ($type->isSubclassOf($otherType->getName())) { 72 | $errors[] = sprintf('Type %s is already caught by %s', $type->getName(), $otherType->getName()); 73 | continue 2; 74 | } 75 | } 76 | } 77 | 78 | return array_unique($errors); 79 | } 80 | 81 | } 82 | -------------------------------------------------------------------------------- /src/Rules/UnreachableCatchRule.php: -------------------------------------------------------------------------------- 1 | 19 | */ 20 | class UnreachableCatchRule implements Rule 21 | { 22 | 23 | private const UNREACHABLE_CATCH_NODE_ATTRIBUTE = '__UNREACHABLE_CATCH_NODE_ATTRIBUTE__'; 24 | 25 | /** 26 | * @var Broker 27 | */ 28 | private $broker; 29 | 30 | public function __construct(Broker $broker) 31 | { 32 | $this->broker = $broker; 33 | } 34 | 35 | public function getNodeType(): string 36 | { 37 | return Stmt::class; 38 | } 39 | 40 | /** 41 | * @return string[] 42 | */ 43 | public function processNode(Node $node, Scope $scope): array 44 | { 45 | if ($node instanceof TryCatch) { 46 | /** @var string[] $caughtClasses */ 47 | $caughtClasses = []; 48 | foreach ($node->catches as $catch) { 49 | $catchClasses = array_map(static function (Name $node): string { 50 | return $node->toString(); 51 | }, $catch->types); 52 | 53 | foreach ($catchClasses as $catchClass) { 54 | if (!$this->broker->hasClass($catchClass)) { 55 | continue; 56 | } 57 | 58 | foreach ($caughtClasses as $caughtClass) { 59 | if (!is_a($catchClass, $caughtClass, true)) { 60 | continue; 61 | } 62 | 63 | $catch->setAttribute( 64 | self::UNREACHABLE_CATCH_NODE_ATTRIBUTE, 65 | sprintf('Superclass of %s has already been caught', $catchClass) 66 | ); 67 | break 2; 68 | } 69 | 70 | $caughtClasses[] = $catchClass; 71 | } 72 | } 73 | 74 | return []; 75 | } 76 | 77 | if ($node instanceof Catch_ && $node->hasAttribute(self::UNREACHABLE_CATCH_NODE_ATTRIBUTE)) { 78 | return [$node->getAttribute(self::UNREACHABLE_CATCH_NODE_ATTRIBUTE)]; 79 | } 80 | 81 | return []; 82 | } 83 | 84 | } 85 | -------------------------------------------------------------------------------- /src/DefaultThrowTypeExtension.php: -------------------------------------------------------------------------------- 1 | defaultThrowTypeService = $defaultThrowTypeService; 27 | } 28 | 29 | /** 30 | * @throws UnsupportedFunctionException 31 | */ 32 | public function getThrowTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type 33 | { 34 | return $this->defaultThrowTypeService->getFunctionThrowType($functionReflection); 35 | } 36 | 37 | /** 38 | * @throws UnsupportedClassException 39 | * @throws UnsupportedFunctionException 40 | */ 41 | public function getThrowTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type 42 | { 43 | return $this->defaultThrowTypeService->getMethodThrowType($methodReflection); 44 | } 45 | 46 | /** 47 | * @throws UnsupportedClassException 48 | * @throws UnsupportedFunctionException 49 | */ 50 | public function getThrowTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): Type 51 | { 52 | return $this->defaultThrowTypeService->getMethodThrowType($methodReflection); 53 | } 54 | 55 | /** 56 | * @throws UnsupportedClassException 57 | */ 58 | public function getThrowTypeFromConstructor(MethodReflection $methodReflection, New_ $newNode, Scope $scope): Type 59 | { 60 | return $this->defaultThrowTypeService->getConstructorThrowType($methodReflection); 61 | } 62 | 63 | } 64 | -------------------------------------------------------------------------------- /src/Extension/DOMDocumentExtension.php: -------------------------------------------------------------------------------- 1 | getDeclaringClass()->getName(), DOMDocument::class, true)) { 31 | throw new UnsupportedClassException(); 32 | } 33 | 34 | if ($methodReflection->getName() === 'load' || $methodReflection->getName() === 'loadHTMLFile') { 35 | return new ObjectType(ErrorException::class); 36 | } 37 | 38 | if ($methodReflection->getName() === 'loadXML' || $methodReflection->getName() === 'loadHTML') { 39 | return $this->resolveLoadSourceType($methodCall, $scope); 40 | } 41 | 42 | throw new UnsupportedFunctionException(); 43 | } 44 | 45 | private function resolveLoadSourceType(MethodCall $methodCall, Scope $scope): Type 46 | { 47 | $valueType = $scope->getType($methodCall->getArgs()[0]->value); 48 | $exceptionType = new ObjectType(ErrorException::class); 49 | 50 | foreach (TypeUtils::getConstantStrings($valueType) as $constantString) { 51 | if ($constantString->getValue() === '') { 52 | return $exceptionType; 53 | } 54 | 55 | $valueType = TypeCombinator::remove($valueType, $constantString); 56 | } 57 | 58 | if (!$valueType instanceof NeverType) { 59 | return $exceptionType; 60 | } 61 | 62 | return new VoidType(); 63 | } 64 | 65 | } 66 | -------------------------------------------------------------------------------- /src/Extension/DateTimeExtension.php: -------------------------------------------------------------------------------- 1 | getDeclaringClass()->getName(), DateTime::class, true) 33 | || is_a($methodReflection->getDeclaringClass()->getName(), DateTimeImmutable::class, true) 34 | ) { 35 | return $this->resolveThrowType($newNode->getArgs(), $scope); 36 | } 37 | 38 | throw new UnsupportedClassException(); 39 | } 40 | 41 | /** 42 | * @param Arg[] $args 43 | */ 44 | private function resolveThrowType(array $args, Scope $scope): Type 45 | { 46 | if (!isset($args[0])) { 47 | return new VoidType(); 48 | } 49 | 50 | $valueType = $scope->getType($args[0]->value); 51 | if ($valueType instanceof NullType) { 52 | return new VoidType(); 53 | } 54 | 55 | $valueType = TypeCombinator::removeNull($valueType); 56 | $exceptionType = new ObjectType(Exception::class); 57 | foreach (TypeUtils::getConstantStrings($valueType) as $constantString) { 58 | try { 59 | new DateTime($constantString->getValue()); 60 | } catch (Exception $e) { 61 | return $exceptionType; 62 | } 63 | 64 | $valueType = TypeCombinator::remove($valueType, $constantString); 65 | } 66 | 67 | if (!$valueType instanceof NeverType) { 68 | return $exceptionType; 69 | } 70 | 71 | return new VoidType(); 72 | } 73 | 74 | } 75 | -------------------------------------------------------------------------------- /src/Extension/IntdivExtension.php: -------------------------------------------------------------------------------- 1 | getName() !== 'intdiv') { 29 | throw new UnsupportedFunctionException(); 30 | } 31 | 32 | $containsMax = false; 33 | $valueType = $scope->getType($functionCall->getArgs()[0]->value); 34 | foreach (TypeUtils::getConstantScalars($valueType) as $constantScalarType) { 35 | if ($constantScalarType->getValue() === PHP_INT_MAX) { 36 | $containsMax = true; 37 | } 38 | 39 | $valueType = TypeCombinator::remove($valueType, $constantScalarType); 40 | } 41 | 42 | if (!$valueType instanceof NeverType) { 43 | return new ObjectType(ArithmeticError::class); 44 | } 45 | 46 | $divisionByZero = false; 47 | $divisorType = $scope->getType($functionCall->getArgs()[1]->value); 48 | foreach (TypeUtils::getConstantScalars($divisorType) as $constantScalarType) { 49 | if ($constantScalarType->getValue() === 0) { 50 | $divisionByZero = true; 51 | } 52 | 53 | if ($containsMax && $constantScalarType->getValue() === -1) { 54 | return new ObjectType(ArithmeticError::class); 55 | } 56 | 57 | $divisorType = TypeCombinator::remove($divisorType, $constantScalarType); 58 | } 59 | 60 | if (!$divisorType instanceof NeverType) { 61 | return new ObjectType(ArithmeticError::class); 62 | } 63 | 64 | if ($divisionByZero) { 65 | return new ObjectType(DivisionByZeroError::class); 66 | } 67 | 68 | return new VoidType(); 69 | } 70 | 71 | } 72 | -------------------------------------------------------------------------------- /src/DefaultThrowTypeService.php: -------------------------------------------------------------------------------- 1 | $methods) { 37 | foreach ($methods as $methodName => $throwTypes) { 38 | if (count($throwTypes) === 0) { 39 | $this->methodThrowTypes[$className][$methodName] = new VoidType(); 40 | continue; 41 | } 42 | 43 | $this->methodThrowTypes[$className][$methodName] = TypeCombinator::union( 44 | ...array_map(static function (string $throwType): ObjectType { 45 | return new ObjectType($throwType); 46 | }, $throwTypes) 47 | ); 48 | } 49 | } 50 | 51 | foreach ($functionThrowTypes as $functionName => $throwTypes) { 52 | if (count($throwTypes) === 0) { 53 | $this->functionThrowTypes[$functionName] = new VoidType(); 54 | continue; 55 | } 56 | 57 | $this->functionThrowTypes[$functionName] = TypeCombinator::union( 58 | ...array_map(static function (string $throwType): ObjectType { 59 | return new ObjectType($throwType); 60 | }, $throwTypes) 61 | ); 62 | } 63 | } 64 | 65 | /** 66 | * @throws UnsupportedFunctionException 67 | */ 68 | public function getFunctionThrowType(FunctionReflection $functionReflection): Type 69 | { 70 | $functionName = $functionReflection->getName(); 71 | if (!isset($this->functionThrowTypes[$functionName])) { 72 | throw new UnsupportedFunctionException(); 73 | } 74 | 75 | return $this->functionThrowTypes[$functionName]; 76 | } 77 | 78 | /** 79 | * @throws UnsupportedClassException 80 | */ 81 | public function getConstructorThrowType(MethodReflection $methodReflection): Type 82 | { 83 | try { 84 | return $this->getMethodThrowType($methodReflection); 85 | } catch (UnsupportedFunctionException $e) { 86 | throw new UnsupportedClassException(); 87 | } 88 | } 89 | 90 | /** 91 | * @throws UnsupportedClassException 92 | * @throws UnsupportedFunctionException 93 | */ 94 | public function getMethodThrowType(MethodReflection $methodReflection): Type 95 | { 96 | $className = $methodReflection->getDeclaringClass()->getName(); 97 | if (!isset($this->methodThrowTypes[$className])) { 98 | throw new UnsupportedClassException(); 99 | } 100 | 101 | if (!isset($this->methodThrowTypes[$className][$methodReflection->getName()])) { 102 | throw new UnsupportedFunctionException(); 103 | } 104 | 105 | return $this->methodThrowTypes[$className][$methodReflection->getName()]; 106 | } 107 | 108 | } 109 | -------------------------------------------------------------------------------- /src/Rules/ThrowsScope.php: -------------------------------------------------------------------------------- 1 | 27 | */ 28 | private $throwsAnnotationBlockStack = [null]; 29 | 30 | /** 31 | * @var bool[][] 32 | */ 33 | private $usedThrowsAnnotationsStack = [[]]; 34 | 35 | /** 36 | * @var TryCatch[][] 37 | */ 38 | private $tryCatchStack = [[]]; 39 | 40 | public function enterToThrowsAnnotationBlock(?Type $type): void 41 | { 42 | $this->stackIndex++; 43 | 44 | $this->throwsAnnotationBlockStack[$this->stackIndex] = $type; 45 | $this->usedThrowsAnnotationsStack[$this->stackIndex] = []; 46 | $this->tryCatchStack[$this->stackIndex] = []; 47 | } 48 | 49 | /** 50 | * @return string[] 51 | */ 52 | public function exitFromThrowsAnnotationBlock(): array 53 | { 54 | $usedThrowsAnnotations = $this->usedThrowsAnnotationsStack[$this->stackIndex]; 55 | 56 | unset($this->throwsAnnotationBlockStack[$this->stackIndex]); 57 | unset($this->usedThrowsAnnotationsStack[$this->stackIndex]); 58 | unset($this->tryCatchStack[$this->stackIndex]); 59 | 60 | $this->stackIndex--; 61 | 62 | return array_keys($usedThrowsAnnotations); 63 | } 64 | 65 | public function isInGlobalScope(): bool 66 | { 67 | return $this->stackIndex === 0; 68 | } 69 | 70 | public function enterToTryCatch(TryCatch $tryCatch): void 71 | { 72 | $this->tryCatchStack[$this->stackIndex][] = $tryCatch; 73 | } 74 | 75 | public function exitFromTry(): void 76 | { 77 | array_pop($this->tryCatchStack[$this->stackIndex]); 78 | } 79 | 80 | /** 81 | * @param string[] $classes 82 | * @return string[] 83 | */ 84 | public function filterExceptionsByUncaught(array $classes): array 85 | { 86 | return array_filter($classes, function (string $class): bool { 87 | return $this->isExceptionCaught($class) === false; 88 | }); 89 | } 90 | 91 | /** 92 | * @return string[] 93 | */ 94 | public function getCaughtExceptions(Name $name): array 95 | { 96 | return $name->getAttribute(self::CAUGHT_EXCEPTIONS_ATTRIBUTE, []); 97 | } 98 | 99 | private function isExceptionCaught(string $exceptionClassName): bool 100 | { 101 | foreach (array_reverse(array_keys($this->tryCatchStack[$this->stackIndex])) as $catchKey) { 102 | $catches = $this->tryCatchStack[$this->stackIndex][$catchKey]; 103 | 104 | foreach ($catches->catches as $catch) { 105 | foreach ($catch->types as $type) { 106 | $catchType = $type->toString(); 107 | $isCaught = is_a($exceptionClassName, $catchType, true); 108 | $isMaybeCaught = is_a($catchType, $exceptionClassName, true); 109 | if (!$isCaught && !$isMaybeCaught) { 110 | continue; 111 | } 112 | 113 | $caughtCheckedExceptions = $type->getAttribute(self::CAUGHT_EXCEPTIONS_ATTRIBUTE, []); 114 | $caughtCheckedExceptions[] = $exceptionClassName; 115 | $type->setAttribute(self::CAUGHT_EXCEPTIONS_ATTRIBUTE, $caughtCheckedExceptions); 116 | 117 | if ($isCaught) { 118 | return true; 119 | } 120 | } 121 | } 122 | } 123 | 124 | if ($this->throwsAnnotationBlockStack[$this->stackIndex] !== null) { 125 | $throwsExceptionClasses = TypeUtils::getDirectClassNames($this->throwsAnnotationBlockStack[$this->stackIndex]); 126 | foreach ($throwsExceptionClasses as $throwsExceptionClass) { 127 | if (is_a($exceptionClassName, $throwsExceptionClass, true)) { 128 | $this->usedThrowsAnnotationsStack[$this->stackIndex][$throwsExceptionClass] = true; 129 | return true; 130 | } 131 | } 132 | } 133 | 134 | return false; 135 | } 136 | 137 | } 138 | -------------------------------------------------------------------------------- /extension.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | exceptionRules: 3 | reportUnusedCatchesOfUncheckedExceptions: false 4 | reportUnusedCheckedThrowsInSubtypes: false 5 | reportCheckedThrowsInGlobalScope: true 6 | checkedExceptions: [] 7 | uncheckedExceptions: [] 8 | methodThrowTypeDeclarations: [] 9 | functionThrowTypeDeclarations: [] 10 | methodWhitelist: [] 11 | 12 | parametersSchema: 13 | exceptionRules: structure([ 14 | reportUnusedCatchesOfUncheckedExceptions: bool() 15 | reportUnusedCheckedThrowsInSubtypes: bool() 16 | reportCheckedThrowsInGlobalScope: bool() 17 | checkedExceptions: listOf(string()) 18 | uncheckedExceptions: listOf(string()) 19 | methodThrowTypeDeclarations: arrayOf(arrayOf(listOf(string()))) 20 | functionThrowTypeDeclarations: arrayOf(listOf(string())) 21 | methodWhitelist: arrayOf(string()) 22 | ]) 23 | 24 | services: 25 | - 26 | class: Pepakriz\PHPStanExceptionRules\DynamicThrowTypeServiceFactory 27 | 28 | - 29 | class: Pepakriz\PHPStanExceptionRules\DynamicThrowTypeService 30 | factory: @Pepakriz\PHPStanExceptionRules\DynamicThrowTypeServiceFactory::create 31 | 32 | - 33 | class: Pepakriz\PHPStanExceptionRules\ThrowsAnnotationReader 34 | arguments: 35 | phpParser: @defaultAnalysisParser 36 | - 37 | class: Pepakriz\PHPStanExceptionRules\CheckedExceptionService 38 | factory: Pepakriz\PHPStanExceptionRules\CheckedExceptionService(%exceptionRules.checkedExceptions%, %exceptionRules.uncheckedExceptions%) 39 | 40 | - 41 | class: Pepakriz\PHPStanExceptionRules\DefaultThrowTypeService 42 | factory: Pepakriz\PHPStanExceptionRules\DefaultThrowTypeService(%exceptionRules.methodThrowTypeDeclarations%, %exceptionRules.functionThrowTypeDeclarations%) 43 | 44 | - 45 | class: Pepakriz\PHPStanExceptionRules\DefaultThrowTypeExtension 46 | tags: 47 | - exceptionRules.dynamicMethodThrowTypeExtension 48 | - exceptionRules.dynamicStaticMethodThrowTypeExtension 49 | - exceptionRules.dynamicConstructorThrowTypeExtension 50 | - exceptionRules.dynamicFunctionThrowTypeExtension 51 | 52 | - 53 | class: Pepakriz\PHPStanExceptionRules\Extension\ReflectionExtension 54 | tags: 55 | - exceptionRules.dynamicConstructorThrowTypeExtension 56 | 57 | - 58 | class: Pepakriz\PHPStanExceptionRules\Extension\DateTimeExtension 59 | tags: 60 | - exceptionRules.dynamicConstructorThrowTypeExtension 61 | 62 | - 63 | class: Pepakriz\PHPStanExceptionRules\Extension\DateIntervalExtension 64 | tags: 65 | - exceptionRules.dynamicConstructorThrowTypeExtension 66 | 67 | - 68 | class: Pepakriz\PHPStanExceptionRules\Extension\SimpleXMLElementExtension 69 | tags: 70 | - exceptionRules.dynamicConstructorThrowTypeExtension 71 | 72 | - 73 | class: Pepakriz\PHPStanExceptionRules\Extension\SplFileObjectExtension 74 | tags: 75 | - exceptionRules.dynamicConstructorThrowTypeExtension 76 | 77 | - 78 | class: Pepakriz\PHPStanExceptionRules\Extension\DOMDocumentExtension 79 | tags: 80 | - exceptionRules.dynamicMethodThrowTypeExtension 81 | 82 | - 83 | class: Pepakriz\PHPStanExceptionRules\Extension\JsonEncodeDecodeExtension 84 | tags: 85 | - exceptionRules.dynamicFunctionThrowTypeExtension 86 | 87 | - 88 | class: Pepakriz\PHPStanExceptionRules\Extension\IntdivExtension 89 | tags: 90 | - exceptionRules.dynamicFunctionThrowTypeExtension 91 | 92 | - 93 | class: Pepakriz\PHPStanExceptionRules\Rules\ThrowsPhpDocRule 94 | arguments: 95 | reportUnusedCatchesOfUncheckedExceptions: %exceptionRules.reportUnusedCatchesOfUncheckedExceptions% 96 | reportUnusedCheckedThrowsInSubtypes: %exceptionRules.reportUnusedCheckedThrowsInSubtypes% 97 | reportCheckedThrowsInGlobalScope: %exceptionRules.reportCheckedThrowsInGlobalScope% 98 | methodWhitelist: %exceptionRules.methodWhitelist% 99 | tags: [phpstan.rules.rule] 100 | 101 | - 102 | class: Pepakriz\PHPStanExceptionRules\Rules\ThrowsPhpDocInheritanceRule 103 | tags: [phpstan.rules.rule] 104 | 105 | - 106 | class: Pepakriz\PHPStanExceptionRules\Rules\UnreachableCatchRule 107 | tags: [phpstan.rules.rule] 108 | 109 | - 110 | class: Pepakriz\PHPStanExceptionRules\Rules\DeadCatchUnionRule 111 | tags: [phpstan.rules.rule] 112 | 113 | - 114 | class: Pepakriz\PHPStanExceptionRules\Rules\UselessThrowsPhpDocRule 115 | tags: [phpstan.rules.rule] 116 | -------------------------------------------------------------------------------- /src/Extension/JsonEncodeDecodeExtension.php: -------------------------------------------------------------------------------- 1 | getName() === 'json_decode') { 34 | if (version_compare(PHP_VERSION, '7.3.0RC1') < 0) { 35 | return new VoidType(); 36 | } 37 | 38 | if (!isset($functionCall->getArgs()[3])) { 39 | return new VoidType(); 40 | } 41 | 42 | $valueType = $scope->getType($functionCall->getArgs()[0]->value); 43 | foreach (TypeUtils::getConstantScalars($valueType) as $constantScalarType) { 44 | try { 45 | json_decode((string) $constantScalarType->getValue(), true, 512, JSON_THROW_ON_ERROR); 46 | $valueType = TypeCombinator::remove($valueType, $constantScalarType); 47 | } catch (JsonException $e) { 48 | // ignore error 49 | } 50 | } 51 | 52 | if ($valueType instanceof NeverType) { 53 | return new VoidType(); 54 | } 55 | 56 | $exceptionType = new ObjectType(JsonException::class); 57 | $optionsType = $scope->getType($functionCall->getArgs()[3]->value); 58 | foreach (TypeUtils::getConstantScalars($optionsType) as $constantScalarType) { 59 | if (!$constantScalarType instanceof IntegerType) { 60 | continue; 61 | } 62 | 63 | if (!$constantScalarType instanceof ConstantIntegerType) { 64 | return $exceptionType; 65 | } 66 | 67 | if (($constantScalarType->getValue() & JSON_THROW_ON_ERROR) === JSON_THROW_ON_ERROR) { 68 | return $exceptionType; 69 | } 70 | 71 | $optionsType = TypeCombinator::remove($optionsType, $constantScalarType); 72 | } 73 | 74 | if (!$optionsType instanceof NeverType) { 75 | return $exceptionType; 76 | } 77 | 78 | return new VoidType(); 79 | } 80 | 81 | if ($functionReflection->getName() === 'json_encode') { 82 | if (version_compare(PHP_VERSION, '7.3.0RC1') < 0) { 83 | return new VoidType(); 84 | } 85 | 86 | if (!isset($functionCall->getArgs()[1])) { 87 | return new VoidType(); 88 | } 89 | 90 | $valueType = $scope->getType($functionCall->getArgs()[0]->value); 91 | foreach (TypeUtils::getConstantScalars($valueType) as $constantScalarType) { 92 | try { 93 | json_encode($constantScalarType->getValue(), JSON_THROW_ON_ERROR); 94 | $valueType = TypeCombinator::remove($valueType, $constantScalarType); 95 | } catch (JsonException $e) { 96 | // ignore error 97 | } 98 | } 99 | 100 | if ($valueType instanceof NeverType) { 101 | return new VoidType(); 102 | } 103 | 104 | $exceptionType = new ObjectType(JsonException::class); 105 | $optionsType = $scope->getType($functionCall->getArgs()[1]->value); 106 | foreach (TypeUtils::getConstantScalars($optionsType) as $constantScalarType) { 107 | if (!$constantScalarType instanceof IntegerType) { 108 | continue; 109 | } 110 | 111 | if (!$constantScalarType instanceof ConstantIntegerType) { 112 | return $exceptionType; 113 | } 114 | 115 | if (($constantScalarType->getValue() & JSON_THROW_ON_ERROR) === JSON_THROW_ON_ERROR) { 116 | return $exceptionType; 117 | } 118 | 119 | $optionsType = TypeCombinator::remove($optionsType, $constantScalarType); 120 | } 121 | 122 | if (!$optionsType instanceof NeverType) { 123 | return $exceptionType; 124 | } 125 | 126 | return new VoidType(); 127 | } 128 | 129 | throw new UnsupportedFunctionException(); 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/Rules/UselessThrowsPhpDocRule.php: -------------------------------------------------------------------------------- 1 | 24 | */ 25 | class UselessThrowsPhpDocRule implements Rule 26 | { 27 | 28 | /** 29 | * @var Broker 30 | */ 31 | private $broker; 32 | 33 | /** 34 | * @var ThrowsAnnotationReader 35 | */ 36 | private $throwsAnnotationReader; 37 | 38 | public function __construct( 39 | Broker $broker, 40 | ThrowsAnnotationReader $throwsAnnotationReader 41 | ) 42 | { 43 | $this->broker = $broker; 44 | $this->throwsAnnotationReader = $throwsAnnotationReader; 45 | } 46 | 47 | public function getNodeType(): string 48 | { 49 | return FunctionLike::class; 50 | } 51 | 52 | /** 53 | * @return string[] 54 | */ 55 | public function processNode(Node $node, Scope $scope): array 56 | { 57 | $docComment = $node->getDocComment(); 58 | if ($docComment === null) { 59 | return []; 60 | } 61 | 62 | if ($node instanceof ClassMethod) { 63 | $classReflection = $scope->getClassReflection(); 64 | if ($classReflection === null) { 65 | throw new ShouldNotHappenException(); 66 | } 67 | 68 | $methodName = $node->name->toString(); 69 | try { 70 | $functionReflection = $classReflection->getMethod($methodName, $scope); 71 | } catch (MissingMethodFromReflectionException $e) { 72 | throw new ShouldNotHappenException(); 73 | } 74 | 75 | } elseif ($node instanceof Function_) { 76 | $functionName = ltrim($scope->getNamespace() . '\\' . $node->name->toString(), '\\'); 77 | try { 78 | $functionReflection = $this->broker->getFunction(new Node\Name\FullyQualified($functionName), $scope); 79 | } catch (FunctionNotFoundException $e) { 80 | throw new ShouldNotHappenException(); 81 | } 82 | 83 | } else { 84 | return []; 85 | } 86 | 87 | $throwsAnnotations = $this->throwsAnnotationReader->readByReflection($functionReflection, $scope); 88 | 89 | try { 90 | return $this->checkUselessThrows($throwsAnnotations); 91 | } catch (ClassNotFoundException $exception) { 92 | return []; 93 | } 94 | } 95 | 96 | /** 97 | * @param string[][] $throwsAnnotations 98 | * @return string[] 99 | * 100 | * @throws ClassNotFoundException 101 | */ 102 | private function checkUselessThrows(array $throwsAnnotations): array 103 | { 104 | /** @var string[] $errors */ 105 | $errors = []; 106 | 107 | $this->sortThrowsAnnotationsHierarchically($throwsAnnotations); 108 | 109 | /** @var bool[] $usefulThrows */ 110 | $usefulThrows = []; 111 | foreach ($throwsAnnotations as $exceptionClass => $descriptions) { 112 | foreach ($descriptions as $description) { 113 | if ( 114 | isset($usefulThrows[$exceptionClass]) 115 | || ( 116 | $description === '' && $this->isSubtypeOfUsefulThrows($exceptionClass, array_keys($usefulThrows)) 117 | ) 118 | ) { 119 | $errors[] = sprintf('Useless @throws %s annotation', $exceptionClass); 120 | } 121 | 122 | $usefulThrows[$exceptionClass] = true; 123 | } 124 | } 125 | 126 | return $errors; 127 | } 128 | 129 | /** 130 | * @param string[] $usefulThrows 131 | * 132 | * @throws ClassNotFoundException 133 | */ 134 | private function isSubtypeOfUsefulThrows(string $exceptionClass, array $usefulThrows): bool 135 | { 136 | $classReflection = $this->broker->getClass($exceptionClass); 137 | 138 | foreach ($usefulThrows as $usefulThrow) { 139 | if ($classReflection->isSubclassOf($usefulThrow)) { 140 | return true; 141 | } 142 | } 143 | 144 | return false; 145 | } 146 | 147 | /** 148 | * @param string[][] $throwsAnnotations 149 | * 150 | * @throws ClassNotFoundException 151 | */ 152 | private function sortThrowsAnnotationsHierarchically(array &$throwsAnnotations): void 153 | { 154 | uksort($throwsAnnotations, function (string $leftClass, string $rightClass): int { 155 | $leftReflection = $this->broker->getClass($leftClass); 156 | $rightReflection = $this->broker->getClass($rightClass); 157 | 158 | // Ensure canonical class names 159 | $leftClass = $leftReflection->getName(); 160 | $rightClass = $rightReflection->getName(); 161 | 162 | if ($leftClass === $rightClass) { 163 | return 0; 164 | } 165 | 166 | if ($leftReflection->isSubclassOf($rightClass)) { 167 | return 1; 168 | } 169 | 170 | if ($rightReflection->isSubclassOf($leftClass)) { 171 | return -1; 172 | } 173 | 174 | // Doesn't matter, sort consistently on classname 175 | return $leftClass <=> $rightClass; 176 | }); 177 | } 178 | 179 | } 180 | -------------------------------------------------------------------------------- /src/Rules/ThrowsPhpDocInheritanceRule.php: -------------------------------------------------------------------------------- 1 | 31 | */ 32 | class ThrowsPhpDocInheritanceRule implements Rule 33 | { 34 | 35 | /** 36 | * @var CheckedExceptionService 37 | */ 38 | private $checkedExceptionService; 39 | 40 | /** 41 | * @var DefaultThrowTypeService 42 | */ 43 | private $defaultThrowTypeService; 44 | 45 | /** 46 | * @var FileTypeMapper 47 | */ 48 | private $fileTypeMapper; 49 | 50 | /** 51 | * @var Broker 52 | */ 53 | private $broker; 54 | 55 | public function __construct( 56 | CheckedExceptionService $checkedExceptionService, 57 | DefaultThrowTypeService $defaultThrowTypeService, 58 | FileTypeMapper $fileTypeMapper, 59 | Broker $broker 60 | ) 61 | { 62 | $this->checkedExceptionService = $checkedExceptionService; 63 | $this->defaultThrowTypeService = $defaultThrowTypeService; 64 | $this->fileTypeMapper = $fileTypeMapper; 65 | $this->broker = $broker; 66 | } 67 | 68 | public function getNodeType(): string 69 | { 70 | return ClassMethod::class; 71 | } 72 | 73 | /** 74 | * @return string[] 75 | */ 76 | public function processNode(Node $node, Scope $scope): array 77 | { 78 | /** @var ClassMethod $node */ 79 | $node = $node; 80 | 81 | $classReflection = $scope->getClassReflection(); 82 | if ($classReflection === null) { 83 | return []; 84 | } 85 | 86 | $docComment = $node->getDocComment(); 87 | if ($docComment === null) { 88 | return []; 89 | } 90 | 91 | $methodName = $node->name->toString(); 92 | if ($methodName === '__construct') { 93 | return []; 94 | } 95 | 96 | $traitReflection = $scope->getTraitReflection(); 97 | $traitName = $traitReflection !== null ? $traitReflection->getName() : null; 98 | 99 | $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( 100 | $scope->getFile(), 101 | $classReflection->getName(), 102 | $traitName, 103 | $methodName, 104 | $docComment->getText() 105 | ); 106 | 107 | $throwsTag = $resolvedPhpDoc->getThrowsTag(); 108 | if ($throwsTag === null || $throwsTag->getType() instanceof VoidType) { 109 | return []; 110 | } 111 | 112 | $throwType = $throwsTag->getType(); 113 | $parentClasses = array_filter( 114 | array_merge($classReflection->getInterfaces(), [$classReflection->getParentClass()]) 115 | ); 116 | 117 | $messages = []; 118 | foreach ($parentClasses as $parentClass) { 119 | try { 120 | $parentClassReflection = $this->broker->getClass($parentClass->getName()); 121 | } catch (ClassNotFoundException $e) { 122 | throw new ShouldNotHappenException(); 123 | } 124 | 125 | try { 126 | $methodReflection = $parentClassReflection->getMethod($methodName, $scope); 127 | } catch (MissingMethodFromReflectionException $e) { 128 | continue; 129 | } 130 | 131 | try { 132 | $parentThrowType = $this->defaultThrowTypeService->getMethodThrowType($methodReflection); 133 | } catch (UnsupportedClassException | UnsupportedFunctionException $e) { 134 | $parentThrowType = $methodReflection->getThrowType(); 135 | } 136 | 137 | if ($parentThrowType === null || $parentThrowType instanceof VoidType) { 138 | $messages[] = sprintf( 139 | 'PHPDoc tag @throws with type %s is not compatible with parent', 140 | $throwType->describe(VerbosityLevel::typeOnly()) 141 | ); 142 | 143 | continue; 144 | } 145 | 146 | $parentThrowType = $this->filterUnchecked($parentThrowType); 147 | if ($parentThrowType === null) { 148 | continue; 149 | } 150 | 151 | if ($parentThrowType->isSuperTypeOf($throwType)->yes()) { 152 | continue; 153 | } 154 | 155 | $messages[] = sprintf( 156 | 'PHPDoc tag @throws with type %s is not compatible with parent %s', 157 | $throwType->describe(VerbosityLevel::typeOnly()), 158 | $parentThrowType->describe(VerbosityLevel::typeOnly()) 159 | ); 160 | } 161 | 162 | return $messages; 163 | } 164 | 165 | private function filterUnchecked(Type $type): ?Type 166 | { 167 | $exceptionClasses = TypeUtils::getDirectClassNames($type); 168 | $exceptionClasses = $this->checkedExceptionService->filterCheckedExceptions($exceptionClasses); 169 | 170 | if (count($exceptionClasses) === 0) { 171 | return null; 172 | } 173 | 174 | $types = []; 175 | foreach ($exceptionClasses as $exceptionClass) { 176 | $types[] = new ObjectType($exceptionClass); 177 | } 178 | 179 | return TypeCombinator::union(...$types); 180 | } 181 | 182 | } 183 | -------------------------------------------------------------------------------- /src/ThrowsAnnotationReader.php: -------------------------------------------------------------------------------- 1 | phpParser = $phpParser; 45 | $this->phpDocLexer = $phpDocLexer; 46 | $this->phpDocParser = $phpDocParser; 47 | } 48 | 49 | /** 50 | * @return string[][] 51 | */ 52 | public function read(Scope $scope): array 53 | { 54 | $reflection = $scope->getFunction(); 55 | 56 | if ($reflection === null) { 57 | return []; 58 | } 59 | 60 | return $this->readByReflection($reflection, $scope); 61 | } 62 | 63 | /** 64 | * @param \PHPStan\Reflection\FunctionReflection|\PHPStan\Reflection\MethodReflection $reflection 65 | * 66 | * @return string[][] 67 | */ 68 | public function readByReflection($reflection, Scope $scope): array 69 | { 70 | $namespace = $scope->getNamespace(); 71 | $sourceFile = $scope->getFile(); 72 | 73 | $key = $namespace . '::' . $sourceFile . '::'; 74 | 75 | $classReflection = $scope->getClassReflection(); 76 | 77 | if ($classReflection !== null) { 78 | $key .= $classReflection->getName(); 79 | } 80 | 81 | $key .= $reflection->getName(); 82 | 83 | if (!isset($this->annotations[$key])) { 84 | $this->annotations[$key] = $this->parse($reflection, $sourceFile, $namespace); 85 | } 86 | 87 | return $this->annotations[$key]; 88 | } 89 | 90 | /** 91 | * @param \PHPStan\Reflection\FunctionReflection|\PHPStan\Reflection\MethodReflection $reflection 92 | * 93 | * @return string[][] 94 | */ 95 | private function parse($reflection, string $sourceFile, ?string $namespace = null): array 96 | { 97 | try { 98 | $docBlock = $this->getDocblock($reflection); 99 | } catch (ReflectionException $exception) { 100 | return []; 101 | } 102 | 103 | if ($docBlock === null) { 104 | return []; 105 | } 106 | 107 | $tokens = new TokenIterator($this->phpDocLexer->tokenize($docBlock)); 108 | $phpDocNode = $this->phpDocParser->parse($tokens); 109 | try { 110 | $nameScope = $this->createNameScope($sourceFile, $namespace); 111 | } catch (ParserErrorsException $exception) { 112 | return []; 113 | } 114 | 115 | $annotations = []; 116 | foreach ($phpDocNode->getThrowsTagValues() as $tagValue) { 117 | $type = $nameScope->resolveStringName((string) $tagValue->type); 118 | 119 | if (!isset($annotations[$type])) { 120 | $annotations[$type] = []; 121 | } 122 | 123 | $annotations[$type][] = $tagValue->description; 124 | } 125 | 126 | return $annotations; 127 | } 128 | 129 | /** 130 | * @param \PHPStan\Reflection\FunctionReflection|\PHPStan\Reflection\MethodReflection $reflection 131 | * 132 | * @throws ReflectionException 133 | */ 134 | private function getDocblock($reflection): ?string 135 | { 136 | if ($reflection instanceof MethodReflection) { 137 | $declaringClass = $reflection->getDeclaringClass(); 138 | $classReflection = $declaringClass->getNativeReflection(); 139 | $methodReflection = $classReflection->getMethod($reflection->getName()); 140 | $docBlock = $methodReflection->getDocComment(); 141 | 142 | while ($docBlock === false) { 143 | try { 144 | $methodReflection = $methodReflection->getPrototype(); 145 | } catch (ReflectionException $exception) { 146 | return null; 147 | } 148 | 149 | $docBlock = $methodReflection->getDocComment(); 150 | } 151 | 152 | return $docBlock !== false ? $docBlock : null; 153 | } 154 | 155 | $functionReflection = new ReflectionFunction($reflection->getName()); 156 | $docBlock = $functionReflection->getDocComment(); 157 | 158 | return $docBlock !== false ? $docBlock : null; 159 | } 160 | 161 | /** 162 | * @throws ParserErrorsException 163 | */ 164 | private function createNameScope(string $sourceFile, ?string $namespace = null): NameScope 165 | { 166 | return new NameScope($namespace, $this->getUsesMap($sourceFile, (string) $namespace)); 167 | } 168 | 169 | /** 170 | * @return string[] 171 | * 172 | * @throws ParserErrorsException 173 | */ 174 | private function getUsesMap(string $fileName, string $namespace): array 175 | { 176 | if (!isset($this->uses[$fileName])) { 177 | $this->uses[$fileName] = $this->createUsesMap($fileName); 178 | } 179 | 180 | return $this->uses[$fileName][$namespace] ?? []; 181 | } 182 | 183 | /** 184 | * @return string[][] 185 | * 186 | * @throws ParserErrorsException 187 | */ 188 | private function createUsesMap(string $sourceFile): array 189 | { 190 | $visitor = new class extends NodeVisitorAbstract { 191 | 192 | /** @var string[][] */ 193 | public $uses = []; 194 | 195 | /** @var string */ 196 | private $namespace = ''; 197 | 198 | public function enterNode(Node $node): ?Node 199 | { 200 | if ($node instanceof Node\Stmt\Namespace_) { 201 | $this->namespace = (string) $node->name; 202 | 203 | return null; 204 | } 205 | 206 | if ($node instanceof Node\Stmt\Use_ && $node->type === Node\Stmt\Use_::TYPE_NORMAL) { 207 | foreach ($node->uses as $use) { 208 | $this->addUse($use->getAlias()->name, (string) $use->name); 209 | } 210 | 211 | return null; 212 | } 213 | 214 | if ($node instanceof Node\Stmt\GroupUse) { 215 | $prefix = (string) $node->prefix; 216 | 217 | foreach ($node->uses as $use) { 218 | if ($node->type !== Node\Stmt\Use_::TYPE_NORMAL && $use->type !== Node\Stmt\Use_::TYPE_NORMAL) { 219 | continue; 220 | } 221 | 222 | $this->addUse($use->getAlias()->name, sprintf('%s\\%s', $prefix, (string) $use->name)); 223 | } 224 | 225 | return null; 226 | } 227 | 228 | return null; 229 | } 230 | 231 | private function addUse(string $alias, string $className): void 232 | { 233 | if (!isset($this->uses[$this->namespace])) { 234 | $this->uses[$this->namespace] = []; 235 | } 236 | 237 | $this->uses[$this->namespace][strtolower($alias)] = $className; 238 | } 239 | 240 | }; 241 | 242 | $traverser = new NodeTraverser(); 243 | $traverser->addVisitor($visitor); 244 | $traverser->traverse($this->phpParser->parseFile($sourceFile)); 245 | 246 | return $visitor->uses; 247 | } 248 | 249 | } 250 | -------------------------------------------------------------------------------- /src/DynamicThrowTypeService.php: -------------------------------------------------------------------------------- 1 | addDynamicMethodExtension($dynamicMethodThrowTypeExtension); 68 | } 69 | 70 | foreach ($dynamicStaticMethodThrowTypeExtensions as $dynamicStaticMethodThrowTypeExtension) { 71 | $this->addDynamicStaticMethodExtension($dynamicStaticMethodThrowTypeExtension); 72 | } 73 | 74 | foreach ($dynamicConstructorThrowTypeExtensions as $dynamicConstructorThrowTypeExtension) { 75 | $this->addDynamicConstructorExtension($dynamicConstructorThrowTypeExtension); 76 | } 77 | 78 | foreach ($dynamicFunctionThrowTypeExtensions as $dynamicFunctionThrowTypeExtension) { 79 | $this->addDynamicFunctionExtension($dynamicFunctionThrowTypeExtension); 80 | } 81 | } 82 | 83 | private function addDynamicMethodExtension(DynamicMethodThrowTypeExtension $extension): void 84 | { 85 | $this->dynamicMethodThrowTypeExtensions[] = $extension; 86 | } 87 | 88 | private function addDynamicStaticMethodExtension(DynamicStaticMethodThrowTypeExtension $extension): void 89 | { 90 | $this->dynamicStaticMethodThrowTypeExtensions[] = $extension; 91 | } 92 | 93 | private function addDynamicConstructorExtension(DynamicConstructorThrowTypeExtension $extension): void 94 | { 95 | $this->dynamicConstructorThrowTypeExtensions[] = $extension; 96 | } 97 | 98 | private function addDynamicFunctionExtension(DynamicFunctionThrowTypeExtension $extension): void 99 | { 100 | $this->dynamicFunctionThrowTypeExtensions[] = $extension; 101 | } 102 | 103 | public function getMethodThrowType(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): Type 104 | { 105 | $classReflection = $methodReflection->getDeclaringClass(); 106 | 107 | $functionName = sprintf('%s::%s', $classReflection->getName(), $methodReflection->getName()); 108 | foreach ($this->dynamicMethodThrowTypeExtensions as $extension) { 109 | $extensionHash = spl_object_hash($extension); 110 | if (isset($this->unsupportedClasses[self::VARIANT_METHOD][$classReflection->getName()][$extensionHash])) { 111 | continue; 112 | } 113 | 114 | if (isset($this->unsupportedFunctions[$functionName][$extensionHash])) { 115 | continue; 116 | } 117 | 118 | try { 119 | return $extension->getThrowTypeFromMethodCall($methodReflection, $methodCall, $scope); 120 | } catch (UnsupportedClassException $e) { 121 | $this->unsupportedClasses[self::VARIANT_METHOD][$classReflection->getName()][$extensionHash] = true; 122 | } catch (UnsupportedFunctionException $e) { 123 | $this->unsupportedFunctions[$functionName][$extensionHash] = true; 124 | } 125 | } 126 | 127 | $throwType = $methodReflection->getThrowType(); 128 | 129 | return $throwType ?? new VoidType(); 130 | } 131 | 132 | public function getStaticMethodThrowType(MethodReflection $methodReflection, StaticCall $staticCall, Scope $scope): Type 133 | { 134 | $classReflection = $methodReflection->getDeclaringClass(); 135 | 136 | $functionName = sprintf('%s::%s', $classReflection->getName(), $methodReflection->getName()); 137 | foreach ($this->dynamicStaticMethodThrowTypeExtensions as $extension) { 138 | $extensionHash = spl_object_hash($extension); 139 | if (isset($this->unsupportedClasses[self::VARIANT_METHOD][$classReflection->getName()][$extensionHash])) { 140 | continue; 141 | } 142 | 143 | if (isset($this->unsupportedFunctions[$functionName][$extensionHash])) { 144 | continue; 145 | } 146 | 147 | try { 148 | return $extension->getThrowTypeFromStaticMethodCall($methodReflection, $staticCall, $scope); 149 | } catch (UnsupportedClassException $e) { 150 | $this->unsupportedClasses[self::VARIANT_METHOD][$classReflection->getName()][$extensionHash] = true; 151 | } catch (UnsupportedFunctionException $e) { 152 | $this->unsupportedFunctions[$functionName][$extensionHash] = true; 153 | } 154 | } 155 | 156 | $throwType = $methodReflection->getThrowType(); 157 | 158 | return $throwType ?? new VoidType(); 159 | } 160 | 161 | public function getConstructorThrowType(MethodReflection $methodReflection, New_ $newNode, Scope $scope): Type 162 | { 163 | $classReflection = $methodReflection->getDeclaringClass(); 164 | 165 | $functionName = sprintf('%s::%s', $classReflection->getName(), $methodReflection->getName()); 166 | foreach ($this->dynamicConstructorThrowTypeExtensions as $extension) { 167 | $extensionHash = spl_object_hash($extension); 168 | if (isset($this->unsupportedClasses[self::VARIANT_CONSTRUCTOR][$classReflection->getName()][$extensionHash])) { 169 | continue; 170 | } 171 | 172 | if (isset($this->unsupportedFunctions[$functionName][$extensionHash])) { 173 | continue; 174 | } 175 | 176 | try { 177 | return $extension->getThrowTypeFromConstructor($methodReflection, $newNode, $scope); 178 | } catch (UnsupportedClassException $e) { 179 | $this->unsupportedClasses[self::VARIANT_CONSTRUCTOR][$classReflection->getName()][$extensionHash] = true; 180 | } 181 | } 182 | 183 | $throwType = $methodReflection->getThrowType(); 184 | 185 | return $throwType ?? new VoidType(); 186 | } 187 | 188 | public function getFunctionThrowType(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type 189 | { 190 | $functionName = $functionReflection->getName(); 191 | foreach ($this->dynamicFunctionThrowTypeExtensions as $extension) { 192 | $extensionHash = spl_object_hash($extension); 193 | if (isset($this->unsupportedFunctions[$functionName][$extensionHash])) { 194 | continue; 195 | } 196 | 197 | try { 198 | return $extension->getThrowTypeFromFunctionCall($functionReflection, $functionCall, $scope); 199 | } catch (UnsupportedFunctionException $e) { 200 | $this->unsupportedFunctions[$functionName][$extensionHash] = true; 201 | } 202 | } 203 | 204 | $throwType = $functionReflection->getThrowType(); 205 | 206 | return $throwType ?? new VoidType(); 207 | } 208 | 209 | } 210 | -------------------------------------------------------------------------------- /src/Extension/ReflectionExtension.php: -------------------------------------------------------------------------------- 1 | broker = $broker; 52 | } 53 | 54 | /** 55 | * @throws UnsupportedClassException 56 | */ 57 | public function getThrowTypeFromConstructor(MethodReflection $methodReflection, New_ $newNode, Scope $scope): Type 58 | { 59 | $className = $methodReflection->getDeclaringClass()->getName(); 60 | 61 | if (is_a($className, ReflectionObject::class, true)) { 62 | return new VoidType(); 63 | } 64 | 65 | if (is_a($className, ReflectionClass::class, true)) { 66 | return $this->resolveReflectionClass($newNode, $scope); 67 | } 68 | 69 | if (is_a($className, ReflectionProperty::class, true)) { 70 | return $this->resolveReflectionProperty($newNode, $scope); 71 | } 72 | 73 | if (is_a($className, ReflectionMethod::class, true)) { 74 | return $this->resolveReflectionMethod($newNode, $scope); 75 | } 76 | 77 | if (is_a($className, ReflectionFunction::class, true)) { 78 | return $this->resolveReflectionFunction($newNode, $scope); 79 | } 80 | 81 | if (is_a($className, ReflectionZendExtension::class, true)) { 82 | return $this->resolveReflectionExtension($newNode, $scope); 83 | } 84 | 85 | throw new UnsupportedClassException(); 86 | } 87 | 88 | private function resolveReflectionClass(New_ $newNode, Scope $scope): Type 89 | { 90 | $reflectionExceptionType = new ObjectType(ReflectionException::class); 91 | if (!isset($newNode->getArgs()[0])) { 92 | return $reflectionExceptionType; 93 | } 94 | 95 | $valueType = $this->resolveType($newNode->getArgs()[0]->value, $scope); 96 | foreach (TypeUtils::getConstantStrings($valueType) as $constantString) { 97 | if (!$this->broker->hasClass($constantString->getValue())) { 98 | return $reflectionExceptionType; 99 | } 100 | 101 | $valueType = TypeCombinator::remove($valueType, $constantString); 102 | } 103 | 104 | if (!$valueType instanceof NeverType) { 105 | return $reflectionExceptionType; 106 | } 107 | 108 | return new VoidType(); 109 | } 110 | 111 | private function resolveReflectionFunction(New_ $newNode, Scope $scope): Type 112 | { 113 | $reflectionExceptionType = new ObjectType(ReflectionException::class); 114 | if (!isset($newNode->getArgs()[0])) { 115 | return $reflectionExceptionType; 116 | } 117 | 118 | $valueType = $this->resolveType($newNode->getArgs()[0]->value, $scope); 119 | foreach (TypeUtils::getConstantStrings($valueType) as $constantString) { 120 | if (!$this->broker->hasFunction(new Name($constantString->getValue()), $scope)) { 121 | return $reflectionExceptionType; 122 | } 123 | 124 | $valueType = TypeCombinator::remove($valueType, $constantString); 125 | } 126 | 127 | if (!$valueType instanceof NeverType) { 128 | return $reflectionExceptionType; 129 | } 130 | 131 | return new VoidType(); 132 | } 133 | 134 | private function resolveReflectionProperty(New_ $newNode, Scope $scope): Type 135 | { 136 | return $this->resolveReflectionMethodOrProperty($newNode, $scope, static function (ClassReflection $classReflection, ConstantStringType $type): bool { 137 | return $classReflection->hasProperty($type->getValue()); 138 | }); 139 | } 140 | 141 | private function resolveReflectionMethod(New_ $newNode, Scope $scope): Type 142 | { 143 | return $this->resolveReflectionMethodOrProperty($newNode, $scope, static function (ClassReflection $classReflection, ConstantStringType $type): bool { 144 | return $classReflection->hasMethod($type->getValue()); 145 | }); 146 | } 147 | 148 | private function resolveReflectionMethodOrProperty(New_ $newNode, Scope $scope, callable $existenceChecker): Type 149 | { 150 | $reflectionExceptionType = new ObjectType(ReflectionException::class); 151 | if (!isset($newNode->getArgs()[1])) { 152 | return $reflectionExceptionType; 153 | } 154 | 155 | $valueType = $this->resolveType($newNode->getArgs()[0]->value, $scope); 156 | $propertyType = $this->resolveType($newNode->getArgs()[1]->value, $scope); 157 | foreach (TypeUtils::getConstantStrings($valueType) as $constantString) { 158 | try { 159 | $classReflection = $this->broker->getClass($constantString->getValue()); 160 | } catch (ClassNotFoundException $e) { 161 | return $reflectionExceptionType; 162 | } 163 | 164 | foreach (TypeUtils::getConstantStrings($propertyType) as $constantPropertyString) { 165 | if (!$existenceChecker($classReflection, $constantPropertyString)) { 166 | return $reflectionExceptionType; 167 | } 168 | } 169 | 170 | $valueType = TypeCombinator::remove($valueType, $constantString); 171 | } 172 | 173 | foreach (TypeUtils::getConstantStrings($propertyType) as $constantPropertyString) { 174 | $propertyType = TypeCombinator::remove($propertyType, $constantPropertyString); 175 | } 176 | 177 | if (!$valueType instanceof NeverType) { 178 | return $reflectionExceptionType; 179 | } 180 | 181 | if (!$propertyType instanceof NeverType) { 182 | return $reflectionExceptionType; 183 | } 184 | 185 | return new VoidType(); 186 | } 187 | 188 | private function resolveReflectionExtension(New_ $newNode, Scope $scope): Type 189 | { 190 | $reflectionExceptionType = new ObjectType(ReflectionException::class); 191 | if (!isset($newNode->getArgs()[0])) { 192 | return $reflectionExceptionType; 193 | } 194 | 195 | $valueType = $this->resolveType($newNode->getArgs()[0]->value, $scope); 196 | foreach (TypeUtils::getConstantStrings($valueType) as $constantString) { 197 | if (!extension_loaded($constantString->getValue())) { 198 | return $reflectionExceptionType; 199 | } 200 | 201 | $valueType = TypeCombinator::remove($valueType, $constantString); 202 | } 203 | 204 | if (!$valueType instanceof NeverType) { 205 | return $reflectionExceptionType; 206 | } 207 | 208 | return new VoidType(); 209 | } 210 | 211 | private function resolveType(Expr $node, Scope $scope): Type 212 | { 213 | $classReflection = $scope->getClassReflection(); 214 | 215 | if ( 216 | $classReflection !== null 217 | && $node instanceof ClassConstFetch 218 | && $node->class instanceof Name 219 | && $node->name instanceof Identifier 220 | && $node->class->toString() === 'static' 221 | && $node->name->toString() === 'class' 222 | ) { 223 | return new ConstantStringType($classReflection->getName()); 224 | } 225 | 226 | $traverser = new NodeTraverser(); 227 | $traverser->addVisitor(new CloningVisitor()); // deep copy 228 | $traverser->addVisitor(new class extends NodeVisitorAbstract { 229 | 230 | public function enterNode(Node $node): Node 231 | { 232 | if ( 233 | $node instanceof ClassConstFetch 234 | && $node->class instanceof Name 235 | && $node->name instanceof Identifier 236 | && $node->class->toString() === 'static' 237 | && $node->name->toString() === 'class' 238 | ) { 239 | $node->class->parts[0] = 'self'; 240 | } 241 | 242 | return $node; 243 | } 244 | 245 | }); 246 | 247 | $node = $traverser->traverse([$node])[0]; 248 | if (!$node instanceof Expr) { 249 | throw new ShouldNotHappenException(); 250 | } 251 | 252 | // Reset the cache to force a new computation 253 | $node->setAttribute('phpstan_cache_printer', null); 254 | 255 | return $scope->getType($node); 256 | } 257 | 258 | } 259 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PHPStan exception rules 2 | 3 | [![Build Status](https://travis-ci.org/pepakriz/phpstan-exception-rules.svg)](https://travis-ci.org/pepakriz/phpstan-exception-rules) 4 | [![Latest Stable Version](https://poser.pugx.org/pepakriz/phpstan-exception-rules/v/stable)](https://packagist.org/packages/pepakriz/phpstan-exception-rules) 5 | [![License](https://poser.pugx.org/pepakriz/phpstan-exception-rules/license)](https://packagist.org/packages/pepakriz/phpstan-exception-rules) 6 | 7 | * [PHPStan](https://phpstan.org/) 8 | 9 | This extension provides following rules and features: 10 | 11 | * Require `@throws` annotation when some checked exception is thrown ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/throws-annotations.php)) 12 | * Exception propagation over: 13 | * Function calls 14 | * Magic, dynamic and static method calls 15 | * Iterable interface in foreach and in `iterator_*()` functions ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/iterators.php)) 16 | * Countable interface combinated with `count()` function ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/countables.php)) 17 | * JsonSerializable interface combinated with `json_encode()` function ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/json-serializable.php)) 18 | * Ignore caught checked exceptions ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/try-catch.php)) 19 | * Unnecessary `@throws` annotation detection ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/unused-throws.php)) 20 | * Useless `@throws` annotation detection ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/useless-throws.php)) 21 | * Optionally allows unused `@throws` annotations in subtypes ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/intentionally-unused-throws.php)) 22 | * `@throws` annotation variance validation ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/throws-inheritance.php)) 23 | * [Dynamic throw types based on arguments](#extensibility) 24 | * Unreachable catch statements 25 | * exception has been caught in some previous catch statement ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/unreachable-catches.php)) 26 | * exception has been caught twice in the same catch statement ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/dead-catch-union.php)) 27 | * checked exception is never thrown in the corresponding try block ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/unused-catches.php)) 28 | * Report throwing checked exceptions in the global scope ([examples](https://github.com/pepakriz/phpstan-exception-rules/blob/master/tests/src/Rules/data/throws-in-global-scope.php)) 29 | 30 | Features and rules provided by PHPStan core (we rely on): 31 | 32 | * `@throws` annotation must contain only valid `Throwable` types 33 | * Thrown value must be subclass of `Throwable` 34 | 35 | ## Usage 36 | 37 | To use this extension, require it in [Composer](https://getcomposer.org/): 38 | 39 | ```bash 40 | composer require --dev pepakriz/phpstan-exception-rules 41 | ``` 42 | 43 | And include and configure extension.neon in your project's PHPStan config: 44 | 45 | ```neon 46 | includes: 47 | - vendor/pepakriz/phpstan-exception-rules/extension.neon 48 | 49 | parameters: 50 | exceptionRules: 51 | reportUnusedCatchesOfUncheckedExceptions: false 52 | reportUnusedCheckedThrowsInSubtypes: false 53 | reportCheckedThrowsInGlobalScope: false 54 | checkedExceptions: 55 | - RuntimeException 56 | ``` 57 | 58 | You could use `uncheckedExceptions` when you prefer a list of unchecked exceptions instead. It is a safer variant, but harder to adapt to the existing project. 59 | 60 | ```neon 61 | parameters: 62 | exceptionRules: 63 | uncheckedExceptions: 64 | - LogicException 65 | - PHPUnit\Framework\Exception 66 | ``` 67 | 68 | > `checkedExceptions` and `uncheckedExceptions` cannot be configured at the same time 69 | 70 | If some third-party code defines wrong throw types (or it doesn't use @throws annotations at all), you could override definitions like this: 71 | 72 | ```neon 73 | parameters: 74 | exceptionRules: 75 | methodThrowTypeDeclarations: 76 | FooProject\SomeService: 77 | sendMessage: 78 | - FooProject\ConnectionTimeoutException 79 | methodWithoutException: [] 80 | functionThrowTypeDeclarations: 81 | myFooFunction: 82 | - FooException 83 | ``` 84 | 85 | In some cases, you may want to ignore exception-related errors as per class basis, as is usually the case for testing: 86 | 87 | ```neon 88 | parameters: 89 | exceptionRules: 90 | methodWhitelist: 91 | PHPUnit\Framework\TestCase: '#^(test|(setup|setupbeforeclass|teardown|teardownafterclass)$)#i' 92 | ``` 93 | 94 | ## Extensibility 95 | 96 | `Dynamic throw type extensions` - If the throw type is not always the same, but depends on an argument passed to the method. (Similar feature as [Dynamic return type extensions](https://phpstan.org/developing-extensions/dynamic-return-type-extensions)) 97 | 98 | There are interfaces, which you can implement: 99 | 100 | * `Pepakriz\PHPStanExceptionRules\DynamicMethodThrowTypeExtension` - service tag: `exceptionRules.dynamicMethodThrowTypeExtension` 101 | * `Pepakriz\PHPStanExceptionRules\DynamicStaticMethodThrowTypeExtension` - service tag: `exceptionRules.dynamicStaticMethodThrowTypeExtension` 102 | * `Pepakriz\PHPStanExceptionRules\DynamicConstructorThrowTypeExtension` - service tag: `exceptionRules.dynamicConstructorThrowTypeExtension` 103 | * `Pepakriz\PHPStanExceptionRules\DynamicFunctionThrowTypeExtension` - service tag: `exceptionRules.dynamicFunctionThrowTypeExtension` 104 | 105 | and register as service with correct tag: 106 | 107 | ```neon 108 | services: 109 | - 110 | class: App\PHPStan\EntityManagerDynamicMethodThrowTypeExtension 111 | tags: 112 | - exceptionRules.dynamicMethodThrowTypeExtension 113 | ``` 114 | 115 | ## Motivation 116 | 117 | There are 2 types of exceptions: 118 | 119 | 1) Safety-checks that something should never happen (you should never call some method in some case etc.). We call these [**LogicException**](http://php.net/manual/en/class.logicexception.php) and if they are thrown, programmer did something wrong. For that reason, it is important that this exception is never caught and kills the application. Also, it is important to write good descriptive message of what went wrong and how to fix it - that is why every LogicException must have a message. Therefore, inheriting LogicException does not make much sense. Also, LogicException should never be `@throws` annotation (see below). 120 | 2) Special cases in business logic which should be handled by application and error cases that just may happen no matter how hard we try (e.g. HTTP request may fail). These exceptions we called [**RuntimeException**](http://php.net/manual/en/class.runtimeexception.php) or maybe better "checked exception". All these exceptions should be checked. Therefore it must be either caught or written in `@throws` annotation. Also if you call an method with that annotation and do not catch the exception, you must propagate it in your `@throws` annotation. This, of course, may spread quickly. When this exception is handled (caught), it is important for programmer to immediately know what case is handled and therefore all used RuntimeExceptions are inherited from some parent and have very descriptive class name (so that you can see it in catch construct) - for example `CannotCloseAccountWithPositiveBalanceException`. The message is not that important since you should always catch these exceptions somewhere, but in our case we often use that message in API output and display it to end-user, so please use something informative for users in that cases (you can pass custom arguments to constructor (e.g. entities) to provide better message). Sometimes you can meet a place where you know that some exception will never be thrown - in this case you can catch it and wrap to LogicException (because when it is thrown, it is a programmer's fault). 121 | 122 | It is always a good idea to wrap previous exception so that we do not lose information of what really happened in some logs. 123 | 124 | ```php 125 | // no throws annotation 126 | public function decide(int $arg): void 127 | { 128 | switch ($arg) { 129 | case self::ONE: 130 | $this->decided() 131 | case self::TWO: 132 | $this->decidedDifferently() 133 | default: 134 | throw new LogicException("Decision cannot be made for argument $arg because of ..."); 135 | } 136 | } 137 | 138 | /** 139 | * @return mixed[] 140 | * 141 | * @throws PrintJobFailedException 142 | */ 143 | private function sendRequest(Request $request): array 144 | { 145 | try { 146 | $response = $this->httpClient->send($request); 147 | return Json::decode((string) $response->getBody(), Json::FORCE_ARRAY); 148 | 149 | } catch (GuzzleException | JsonException $e) { 150 | throw new PrintJobFailedException($e); 151 | } 152 | } 153 | 154 | class PrintJobFailedException extends RuntimeException 155 | { 156 | 157 | public function __construct(Throwable $previous) 158 | { 159 | parent::__construct('Printing failed, remote printing service is down. Please try again later', $previous); 160 | } 161 | 162 | } 163 | ``` 164 | 165 | ## Known limitations 166 | 167 | #### Anonymous functions are analyzed at the same place they are declared 168 | 169 | _False positive when a method does not execute declared function:_ 170 | 171 | ```php 172 | /** 173 | * @throws FooRuntimeException false positive 174 | */ 175 | public function createFnFoo(int $arg): callable 176 | { 177 | return function () { 178 | throw new FooRuntimeException(); 179 | }; 180 | } 181 | ``` 182 | 183 | _But most of use-cases just works:_ 184 | 185 | ```php 186 | /** 187 | * @param string[] $rows 188 | * @return string[] 189 | * 190 | * @throws EmptyLineException 191 | */ 192 | public function normalizeRows(array $rows): array 193 | { 194 | return array_map(function (string $row): string { 195 | $row = trim($row); 196 | if ($row === '') { 197 | throw new EmptyLineException(); 198 | } 199 | 200 | return $row; 201 | }, $rows); 202 | } 203 | ``` 204 | 205 | #### `Catch` statement does not know about runtime subtypes 206 | 207 | This case is detected by rule, so you will be warned about a potential risk. 208 | 209 | _Runtime exception is absorbed:_ 210 | 211 | ```php 212 | // @throws phpdoc is not required 213 | public function methodWithoutThrowsPhpDoc(): void 214 | { 215 | try { 216 | throw new RuntimeException(); 217 | $this->dangerousCall(); 218 | 219 | } catch (Throwable $e) { 220 | throw $e; 221 | } 222 | } 223 | ``` 224 | 225 | _As a workaround you could use custom catch statement:_ 226 | 227 | ```php 228 | /** 229 | * @throws RuntimeException 230 | */ 231 | public function methodWithThrowsPhpDoc(): void 232 | { 233 | try { 234 | throw new RuntimeException(); 235 | $this->dangerousCall(); 236 | 237 | } catch (RuntimeException $e) { 238 | throw $e; 239 | } catch (Throwable $e) { 240 | throw $e; 241 | } 242 | } 243 | ``` 244 | -------------------------------------------------------------------------------- /src/Rules/ThrowsPhpDocRule.php: -------------------------------------------------------------------------------- 1 | 64 | */ 65 | class ThrowsPhpDocRule implements Rule 66 | { 67 | 68 | private const ATTRIBUTE_HAS_CLASS_METHOD_END = '__HAS_CLASS_METHOD_END__'; 69 | private const ATTRIBUTE_HAS_TRY_CATCH_END = '__HAS_TRY_CATCH_END__'; 70 | 71 | private const ITERATOR_METHODS_WITHOUT_KEY = ['rewind', 'valid', 'current', 'next']; 72 | private const ITERATOR_METHODS = self::ITERATOR_METHODS_WITHOUT_KEY + ['key']; 73 | private const ITERATOR_AGGREGATE_METHODS = ['getIterator']; 74 | 75 | /** 76 | * @var CheckedExceptionService 77 | */ 78 | private $checkedExceptionService; 79 | 80 | /** 81 | * @var DynamicThrowTypeService 82 | */ 83 | private $dynamicThrowTypeService; 84 | 85 | /** 86 | * @var DefaultThrowTypeService 87 | */ 88 | private $defaultThrowTypeService; 89 | 90 | /** 91 | * @var ThrowsAnnotationReader 92 | */ 93 | private $throwsAnnotationReader; 94 | 95 | /** 96 | * @var Broker 97 | */ 98 | private $broker; 99 | 100 | /** 101 | * @var ThrowsScope 102 | */ 103 | private $throwsScope; 104 | 105 | /** 106 | * @var bool 107 | */ 108 | private $reportUnusedCatchesOfUncheckedExceptions; 109 | 110 | /** 111 | * @var bool 112 | */ 113 | private $reportCheckedThrowsInGlobalScope; 114 | 115 | /** 116 | * @var bool 117 | */ 118 | private $reportUnusedCheckedThrowsInSubtypes; 119 | 120 | /** @var string[] */ 121 | private $methodWhitelist; 122 | 123 | /** 124 | * @param string[] $methodWhitelist 125 | */ 126 | public function __construct( 127 | CheckedExceptionService $checkedExceptionService, 128 | DynamicThrowTypeService $dynamicThrowTypeService, 129 | DefaultThrowTypeService $defaultThrowTypeService, 130 | ThrowsAnnotationReader $throwsAnnotationReader, 131 | Broker $broker, 132 | bool $reportUnusedCatchesOfUncheckedExceptions, 133 | bool $reportUnusedCheckedThrowsInSubtypes, 134 | bool $reportCheckedThrowsInGlobalScope, 135 | array $methodWhitelist 136 | ) 137 | { 138 | $this->checkedExceptionService = $checkedExceptionService; 139 | $this->dynamicThrowTypeService = $dynamicThrowTypeService; 140 | $this->defaultThrowTypeService = $defaultThrowTypeService; 141 | $this->throwsAnnotationReader = $throwsAnnotationReader; 142 | $this->broker = $broker; 143 | $this->throwsScope = new ThrowsScope(); 144 | $this->reportUnusedCatchesOfUncheckedExceptions = $reportUnusedCatchesOfUncheckedExceptions; 145 | $this->reportCheckedThrowsInGlobalScope = $reportCheckedThrowsInGlobalScope; 146 | $this->reportUnusedCheckedThrowsInSubtypes = $reportUnusedCheckedThrowsInSubtypes; 147 | $this->methodWhitelist = $methodWhitelist; 148 | } 149 | 150 | public function getNodeType(): string 151 | { 152 | return Node::class; 153 | } 154 | 155 | /** 156 | * @return RuleError[] 157 | */ 158 | public function processNode(Node $node, Scope $scope): array 159 | { 160 | if ($node instanceof UnreachableStatementNode) { 161 | return $this->processNode($node->getOriginalStatement(), $scope); 162 | } 163 | 164 | if ($node instanceof Node\FunctionLike) { 165 | return $this->processFunction($node, $scope); 166 | } 167 | 168 | $method = $scope->getFunction(); 169 | $isMethodWhitelisted = $method instanceof MethodReflection && $this->isWhitelistedMethod($method); 170 | if ($node instanceof MethodReturnStatementsNode) { 171 | if ($isMethodWhitelisted) { 172 | return $this->processWhitelistedMethod($method, $node->getStartLine()); 173 | } 174 | 175 | return $this->processFunctionEnd($scope, $node->getStartLine()); 176 | } 177 | 178 | if ($node instanceof FunctionReturnStatementsNode) { 179 | return $this->processFunctionEnd($scope, $node->getStartLine()); 180 | } 181 | 182 | if ($isMethodWhitelisted) { 183 | return []; 184 | } 185 | 186 | if ($node instanceof TryCatch) { 187 | return $this->processTryCatch($node); 188 | } 189 | 190 | if ($node instanceof TryCatchTryEnd) { 191 | return $this->processTryCatchTryEnd(); 192 | } 193 | 194 | if ($node instanceof Throw_) { 195 | return $this->processThrow($node, $scope); 196 | } 197 | 198 | if ($node instanceof Expr\Throw_) { 199 | return $this->processExprThrow($node, $scope); 200 | } 201 | 202 | if ($node instanceof MethodCall) { 203 | return $this->processMethodCall($node, $scope); 204 | } 205 | 206 | if ($node instanceof StaticCall) { 207 | return $this->processStaticCall($node, $scope); 208 | } 209 | 210 | if ($node instanceof New_) { 211 | return $this->processNew($node, $scope); 212 | } 213 | 214 | if ($node instanceof Expr\YieldFrom) { 215 | return $this->processExprTraversing($node->expr, $scope, true); 216 | } 217 | 218 | if ($node instanceof Catch_) { 219 | return $this->processCatch($node); 220 | } 221 | 222 | if ($node instanceof Foreach_) { 223 | return $this->processExprTraversing($node->expr, $scope, $node->keyVar !== null); 224 | } 225 | 226 | if ($node instanceof FuncCall) { 227 | return $this->processFuncCall($node, $scope); 228 | } 229 | 230 | if ($node instanceof Expr\BinaryOp\Div || $node instanceof Expr\BinaryOp\Mod) { 231 | return $this->processDiv($node->right, $scope); 232 | } 233 | 234 | if ($node instanceof Expr\AssignOp\Div || $node instanceof Expr\AssignOp\Mod) { 235 | return $this->processDiv($node->expr, $scope); 236 | } 237 | 238 | if ($node instanceof Expr\BinaryOp\ShiftLeft || $node instanceof Expr\BinaryOp\ShiftRight) { 239 | return $this->processShift($node->right, $scope); 240 | } 241 | 242 | if ($node instanceof Expr\AssignOp\ShiftLeft || $node instanceof Expr\AssignOp\ShiftRight) { 243 | return $this->processShift($node->expr, $scope); 244 | } 245 | 246 | return []; 247 | } 248 | 249 | /** 250 | * @return RuleError[] 251 | */ 252 | private function processWhitelistedMethod(MethodReflection $methodReflection, int $startLine): array 253 | { 254 | $throwType = $methodReflection->getThrowType(); 255 | 256 | if ($throwType === null) { 257 | return []; 258 | } 259 | 260 | return array_map( 261 | static function (string $throwClass) use ($startLine): RuleError { 262 | return RuleErrorBuilder::message(sprintf('Unused @throws %s annotation', $throwClass)) 263 | ->line($startLine) 264 | ->build(); 265 | }, 266 | TypeUtils::getDirectClassNames($throwType) 267 | ); 268 | } 269 | 270 | private function isWhitelistedMethod(MethodReflection $methodReflection): bool 271 | { 272 | $classReflection = $methodReflection->getDeclaringClass(); 273 | 274 | foreach ($this->methodWhitelist as $className => $pattern) { 275 | if (!$classReflection->isSubclassOf($className)) { 276 | continue; 277 | } 278 | 279 | if (preg_match($pattern, $methodReflection->getName()) === 1) { 280 | return true; 281 | } 282 | } 283 | 284 | return false; 285 | } 286 | 287 | /** 288 | * @return RuleError[] 289 | */ 290 | private function processTryCatch(TryCatch $node): array 291 | { 292 | $this->throwsScope->enterToTryCatch($node); 293 | 294 | if (!$node->hasAttribute(self::ATTRIBUTE_HAS_TRY_CATCH_END)) { 295 | $node->setAttribute(self::ATTRIBUTE_HAS_TRY_CATCH_END, true); 296 | $node->stmts[] = new TryCatchTryEnd($node); 297 | } 298 | 299 | return []; 300 | } 301 | 302 | /** 303 | * @return RuleError[] 304 | */ 305 | private function processTryCatchTryEnd(): array 306 | { 307 | $this->throwsScope->exitFromTry(); 308 | 309 | return []; 310 | } 311 | 312 | /** 313 | * @return RuleError[] 314 | */ 315 | private function processThrow(Throw_ $node, Scope $scope): array 316 | { 317 | $exceptionType = $scope->getType($node->expr); 318 | 319 | return $this->processThrowsTypes($exceptionType); 320 | } 321 | 322 | /** 323 | * @return RuleError[] 324 | */ 325 | private function processExprThrow(Expr\Throw_ $node, Scope $scope): array 326 | { 327 | $exceptionType = $scope->getType($node->expr); 328 | 329 | return $this->processThrowsTypes($exceptionType); 330 | } 331 | 332 | /** 333 | * @return RuleError[] 334 | */ 335 | private function processMethodCall(MethodCall $node, Scope $scope): array 336 | { 337 | $methodName = $node->name; 338 | if (!$methodName instanceof Identifier) { 339 | return []; 340 | } 341 | 342 | $targetType = $scope->getType($node->var); 343 | $targetClassNames = TypeUtils::getDirectClassNames($targetType); 344 | 345 | $throwTypes = []; 346 | foreach ($targetClassNames as $targetClassName) { 347 | try { 348 | $targetClassReflection = $this->broker->getClass($targetClassName); 349 | } catch (ClassNotFoundException $e) { 350 | continue; 351 | } 352 | 353 | try { 354 | $targetMethodReflection = $targetClassReflection->getMethod($methodName->toString(), $scope); 355 | } catch (MissingMethodFromReflectionException $e) { 356 | try { 357 | $targetMethodReflection = $targetClassReflection->getMethod('__call', $scope); 358 | } catch (MissingMethodFromReflectionException $e) { 359 | continue; 360 | } 361 | } 362 | 363 | $throwType = $this->dynamicThrowTypeService->getMethodThrowType($targetMethodReflection, $node, $scope); 364 | if ($throwType instanceof VoidType) { 365 | continue; 366 | } 367 | 368 | $throwTypes[] = $throwType; 369 | } 370 | 371 | if (count($throwTypes) === 0) { 372 | return []; 373 | } 374 | 375 | return $this->processThrowsTypes(TypeCombinator::union(...$throwTypes)); 376 | } 377 | 378 | /** 379 | * @return RuleError[] 380 | */ 381 | private function processStaticCall(StaticCall $node, Scope $scope): array 382 | { 383 | $methodName = $node->name; 384 | if ($methodName instanceof Identifier) { 385 | $methodName = $methodName->toString(); 386 | } 387 | 388 | if (!is_string($methodName)) { 389 | return []; 390 | } 391 | 392 | $throwTypes = []; 393 | $targetMethodReflections = $this->getMethodReflections($node->class, [$methodName], $scope); 394 | 395 | if (count($targetMethodReflections) === 0) { 396 | $targetMethodReflections = $this->getMethodReflections($node->class, ['__callStatic'], $scope); 397 | } 398 | 399 | foreach ($targetMethodReflections as $targetMethodReflection) { 400 | $throwType = $this->dynamicThrowTypeService->getStaticMethodThrowType($targetMethodReflection, $node, $scope); 401 | if ($throwType instanceof VoidType) { 402 | continue; 403 | } 404 | 405 | $throwTypes[] = $throwType; 406 | } 407 | 408 | if (count($throwTypes) === 0) { 409 | return []; 410 | } 411 | 412 | return $this->processThrowsTypes(TypeCombinator::union(...$throwTypes)); 413 | } 414 | 415 | /** 416 | * @return RuleError[] 417 | */ 418 | private function processNew(New_ $node, Scope $scope): array 419 | { 420 | $throwTypes = []; 421 | $targetMethodReflections = $this->getMethodReflections($node->class, ['__construct'], $scope); 422 | foreach ($targetMethodReflections as $targetMethodReflection) { 423 | $throwType = $this->dynamicThrowTypeService->getConstructorThrowType($targetMethodReflection, $node, $scope); 424 | if ($throwType instanceof VoidType) { 425 | continue; 426 | } 427 | 428 | $throwTypes[] = $throwType; 429 | } 430 | 431 | if (count($throwTypes) === 0) { 432 | return []; 433 | } 434 | 435 | return $this->processThrowsTypes(TypeCombinator::union(...$throwTypes)); 436 | } 437 | 438 | /** 439 | * @return RuleError[] 440 | */ 441 | private function processExprTraversing(Expr $expr, Scope $scope, bool $useKey): array 442 | { 443 | $type = $scope->getType($expr); 444 | 445 | $messages = []; 446 | $classNames = TypeUtils::getDirectClassNames($type); 447 | foreach ($classNames as $className) { 448 | try { 449 | $classReflection = $this->broker->getClass($className); 450 | } catch (ClassNotFoundException $e) { 451 | continue; 452 | } 453 | 454 | if ($classReflection->isSubclassOf(Iterator::class)) { 455 | $messages = array_merge($messages, $this->processThrowTypesOnMethod( 456 | $expr, 457 | $useKey ? self::ITERATOR_METHODS : self::ITERATOR_METHODS_WITHOUT_KEY, 458 | $scope 459 | )); 460 | } elseif ($classReflection->isSubclassOf(IteratorAggregate::class)) { 461 | $messages = array_merge($messages, $this->processThrowTypesOnMethod( 462 | $expr, 463 | self::ITERATOR_AGGREGATE_METHODS, 464 | $scope 465 | )); 466 | } 467 | } 468 | 469 | return array_unique($messages); 470 | } 471 | 472 | /** 473 | * @return RuleError[] 474 | */ 475 | private function processFunction(Node\FunctionLike $node, Scope $scope): array 476 | { 477 | if ( 478 | !$node instanceof ClassMethod 479 | && !$node instanceof Node\Stmt\Function_ 480 | ) { 481 | return []; 482 | } 483 | 484 | $classReflection = $scope->getTraitReflection(); 485 | if ($classReflection === null) { 486 | $classReflection = $scope->getClassReflection(); 487 | } 488 | 489 | if ($classReflection === null) { 490 | try { 491 | $methodReflection = $this->broker->getFunction(new Name($node->name->toString()), $scope); 492 | } catch (FunctionNotFoundException $e) { 493 | return []; 494 | } 495 | } else { 496 | try { 497 | $methodReflection = $classReflection->getMethod($node->name->toString(), $scope); 498 | } catch (MissingMethodFromReflectionException $e) { 499 | throw new ShouldNotHappenException(); 500 | } 501 | 502 | try { 503 | $nativeMethodReflection = $classReflection->getNativeReflection()->getMethod( 504 | $methodReflection->getName() 505 | ); 506 | } catch (ReflectionException $exception) { 507 | throw new ShouldNotHappenException(); 508 | } 509 | 510 | if ($nativeMethodReflection->isAbstract()) { 511 | return []; 512 | } 513 | } 514 | 515 | $this->throwsScope->enterToThrowsAnnotationBlock($methodReflection->getThrowType()); 516 | 517 | if (!$node->hasAttribute(self::ATTRIBUTE_HAS_CLASS_METHOD_END)) { 518 | $node->setAttribute(self::ATTRIBUTE_HAS_CLASS_METHOD_END, true); 519 | } 520 | 521 | return []; 522 | } 523 | 524 | /** 525 | * @return RuleError[] 526 | */ 527 | private function processFunctionEnd(Scope $scope, int $startLine): array 528 | { 529 | $usedThrowsAnnotations = $this->throwsScope->exitFromThrowsAnnotationBlock(); 530 | 531 | $functionReflection = $scope->getFunction(); 532 | if ($functionReflection === null) { 533 | return []; 534 | } 535 | 536 | $classReflection = $scope->getClassReflection(); 537 | if ($classReflection !== null && ($classReflection->isInterface() || $classReflection->isAbstract())) { 538 | return []; 539 | } 540 | 541 | $throwType = $functionReflection->getThrowType(); 542 | if ($throwType === null) { 543 | return []; 544 | } 545 | 546 | $declaredThrows = TypeUtils::getDirectClassNames($throwType); 547 | $unusedThrows = $this->filterUnusedExceptions($declaredThrows, $usedThrowsAnnotations, $scope); 548 | 549 | $messages = []; 550 | foreach ($unusedThrows as $unusedClass) { 551 | $messages[] = RuleErrorBuilder::message(sprintf('Unused @throws %s annotation', $unusedClass)) 552 | ->line($startLine) 553 | ->build(); 554 | } 555 | 556 | return $messages; 557 | } 558 | 559 | /** 560 | * @param string[] $declaredThrows 561 | * @param string[] $usedThrowsAnnotations 562 | * 563 | * @return string[] 564 | */ 565 | private function filterUnusedExceptions(array $declaredThrows, array $usedThrowsAnnotations, Scope $scope): array 566 | { 567 | $unusedThrows = array_diff($declaredThrows, $usedThrowsAnnotations); 568 | 569 | $functionReflection = $scope->getFunction(); 570 | if ($functionReflection === null) { 571 | return $unusedThrows; 572 | } 573 | 574 | if (!$this->reportUnusedCheckedThrowsInSubtypes && $functionReflection instanceof MethodReflection) { 575 | $declaringClass = $functionReflection->getDeclaringClass(); 576 | 577 | try { 578 | $nativeMethodReflection = $declaringClass->getNativeReflection()->getMethod( 579 | $functionReflection->getName() 580 | ); 581 | } catch (ReflectionException $exception) { 582 | throw new ShouldNotHappenException(); 583 | } 584 | 585 | if ($this->isImplementation($nativeMethodReflection)) { 586 | return []; 587 | } 588 | } 589 | 590 | try { 591 | if ($functionReflection instanceof MethodReflection) { 592 | $defaultThrowsType = $functionReflection->getName() === '__construct' ? 593 | $this->defaultThrowTypeService->getConstructorThrowType($functionReflection) : 594 | $this->defaultThrowTypeService->getMethodThrowType($functionReflection); 595 | } else { 596 | $defaultThrowsType = $this->defaultThrowTypeService->getFunctionThrowType($functionReflection); 597 | } 598 | } catch (UnsupportedClassException | UnsupportedFunctionException $exception) { 599 | $defaultThrowsType = new VoidType(); 600 | } 601 | 602 | $unusedThrows = array_diff($unusedThrows, TypeUtils::getDirectClassNames($defaultThrowsType)); 603 | 604 | $throwsAnnotations = $this->throwsAnnotationReader->read($scope); 605 | 606 | return array_filter($unusedThrows, static function (string $type) use ($throwsAnnotations, $usedThrowsAnnotations): bool { 607 | return !in_array($type, $usedThrowsAnnotations, true) 608 | || !isset($throwsAnnotations[$type]) 609 | || in_array('', $throwsAnnotations[$type], true); 610 | }); 611 | } 612 | 613 | private function isImplementation(ReflectionMethod $reflection): bool 614 | { 615 | if ($reflection->isAbstract()) { 616 | return false; 617 | } 618 | 619 | try { 620 | $reflection->getPrototype(); 621 | } catch (ReflectionException $exception) { 622 | return false; 623 | } 624 | 625 | return true; 626 | } 627 | 628 | /** 629 | * @return RuleError[] 630 | */ 631 | private function processCatch(Catch_ $node): array 632 | { 633 | $messages = []; 634 | 635 | foreach ($node->types as $type) { 636 | $caughtExceptions = $this->throwsScope->getCaughtExceptions($type); 637 | 638 | $caughtChecked = []; 639 | foreach ($caughtExceptions as $caughtException) { 640 | if (!$this->checkedExceptionService->isCheckedException($caughtException)) { 641 | continue; 642 | } 643 | 644 | $caughtChecked[] = $caughtException; 645 | } 646 | 647 | if (!$this->checkedExceptionService->isCheckedException($type->toString())) { 648 | foreach ($caughtChecked as $caughtCheckedException) { 649 | $messages[] = RuleErrorBuilder::message(sprintf( 650 | 'Catching checked exception %s as unchecked %s is not supported properly in this moment. Eliminate checked exceptions by custom catch statement.', 651 | $caughtCheckedException, 652 | $type->toString() 653 | ))->build(); 654 | } 655 | } 656 | 657 | $exceptionClass = $type->toString(); 658 | if ( 659 | !$this->reportUnusedCatchesOfUncheckedExceptions 660 | && !$this->checkedExceptionService->isCheckedException($exceptionClass) 661 | ) { 662 | continue; 663 | } 664 | 665 | if (count($caughtExceptions) > 0) { 666 | continue; 667 | } 668 | 669 | $messages[] = RuleErrorBuilder::message(sprintf('%s is never thrown in the corresponding try block', $exceptionClass))->build(); 670 | } 671 | 672 | return $messages; 673 | } 674 | 675 | /** 676 | * @return RuleError[] 677 | */ 678 | private function processFuncCall(FuncCall $node, Scope $scope): array 679 | { 680 | $nodeName = $node->name; 681 | if (!$nodeName instanceof Name) { 682 | return []; // closure call 683 | } 684 | 685 | $functionName = $nodeName->toString(); 686 | if ($functionName === 'count') { 687 | return $this->processThrowTypesOnMethod($node->getArgs()[0]->value, ['count'], $scope); 688 | } 689 | 690 | if ($functionName === 'iterator_count') { 691 | return $this->processThrowTypesOnMethod($node->getArgs()[0]->value, ['rewind', 'valid', 'next'], $scope); 692 | } 693 | 694 | if ($functionName === 'iterator_to_array') { 695 | return $this->processThrowTypesOnMethod($node->getArgs()[0]->value, ['rewind', 'valid', 'current', 'key', 'next'], $scope); 696 | } 697 | 698 | if ($functionName === 'iterator_apply') { 699 | return $this->processThrowTypesOnMethod($node->getArgs()[0]->value, ['rewind', 'valid', 'next'], $scope); 700 | } 701 | 702 | try { 703 | $functionReflection = $this->broker->getFunction($nodeName, $scope); 704 | } catch (FunctionNotFoundException $e) { 705 | return []; 706 | } 707 | 708 | $throwType = $this->dynamicThrowTypeService->getFunctionThrowType($functionReflection, $node, $scope); 709 | 710 | if ($functionName === 'json_encode') { 711 | $throwType = TypeCombinator::union( 712 | $throwType, 713 | ...$this->getThrowTypesOnMethod($node->getArgs()[0]->value, ['jsonSerialize'], $scope) 714 | ); 715 | } 716 | 717 | return $this->processThrowsTypes($throwType); 718 | } 719 | 720 | /** 721 | * @return RuleError[] 722 | */ 723 | private function processDiv(Expr $divisor, Scope $scope): array 724 | { 725 | $divisorType = $scope->getType($divisor); 726 | foreach (TypeUtils::getConstantScalars($divisorType) as $constantScalarType) { 727 | if ($constantScalarType->getValue() === 0) { 728 | return $this->processThrowsTypes(new ObjectType(DivisionByZeroError::class)); 729 | } 730 | 731 | $divisorType = TypeCombinator::remove($divisorType, $constantScalarType); 732 | } 733 | 734 | if (!$divisorType instanceof NeverType) { 735 | return $this->processThrowsTypes(new ObjectType(DivisionByZeroError::class)); 736 | } 737 | 738 | return []; 739 | } 740 | 741 | /** 742 | * @return RuleError[] 743 | */ 744 | private function processShift(Expr $value, Scope $scope): array 745 | { 746 | $valueType = $scope->getType($value); 747 | foreach (TypeUtils::getConstantScalars($valueType) as $constantScalarType) { 748 | if ($constantScalarType->getValue() < 0) { 749 | return $this->processThrowsTypes(new ObjectType(ArithmeticError::class)); 750 | } 751 | 752 | $valueType = TypeCombinator::remove($valueType, $constantScalarType); 753 | } 754 | 755 | if (!$valueType instanceof NeverType) { 756 | return $this->processThrowsTypes(new ObjectType(ArithmeticError::class)); 757 | } 758 | 759 | return []; 760 | } 761 | 762 | /** 763 | * @param Name|Expr|ClassLike $class 764 | * @param string[] $methods 765 | * @return Type[] 766 | */ 767 | private function getThrowTypesOnMethod($class, array $methods, Scope $scope): array 768 | { 769 | /** @var Type[] $throwTypes */ 770 | $throwTypes = []; 771 | $targetMethodReflections = $this->getMethodReflections($class, $methods, $scope); 772 | foreach ($targetMethodReflections as $targetMethodReflection) { 773 | $throwType = $targetMethodReflection->getThrowType(); 774 | if ($throwType === null) { 775 | continue; 776 | } 777 | 778 | $throwTypes[] = $throwType; 779 | } 780 | 781 | if (count($throwTypes) === 0) { 782 | return []; 783 | } 784 | 785 | return $throwTypes; 786 | } 787 | 788 | /** 789 | * @param Name|Expr|ClassLike $class 790 | * @param string[] $methods 791 | * @return RuleError[] 792 | */ 793 | private function processThrowTypesOnMethod($class, array $methods, Scope $scope): array 794 | { 795 | $throwTypes = $this->getThrowTypesOnMethod($class, $methods, $scope); 796 | 797 | return $this->processThrowsTypes(TypeCombinator::union(...$throwTypes)); 798 | } 799 | 800 | /** 801 | * @return RuleError[] 802 | */ 803 | private function processThrowsTypes(Type $targetThrowType): array 804 | { 805 | $targetExceptionClasses = TypeUtils::getDirectClassNames($targetThrowType); 806 | $targetExceptionClasses = $this->throwsScope->filterExceptionsByUncaught($targetExceptionClasses); 807 | $targetExceptionClasses = $this->checkedExceptionService->filterCheckedExceptions($targetExceptionClasses); 808 | 809 | $isInGlobalScope = $this->throwsScope->isInGlobalScope(); 810 | if (!$this->reportCheckedThrowsInGlobalScope && $isInGlobalScope) { 811 | return []; 812 | } 813 | 814 | return array_map(static function (string $exceptionClassName) use ($isInGlobalScope): RuleError { 815 | if ($isInGlobalScope) { 816 | return RuleErrorBuilder::message(sprintf('Throwing checked exception %s in global scope is prohibited', $exceptionClassName))->build(); 817 | } 818 | 819 | return RuleErrorBuilder::message(sprintf('Missing @throws %s annotation', $exceptionClassName))->build(); 820 | }, $targetExceptionClasses); 821 | } 822 | 823 | /** 824 | * @param Name|Expr|ClassLike $class 825 | * @param string[] $methodNames 826 | * @return MethodReflection[] 827 | */ 828 | private function getMethodReflections( 829 | $class, 830 | array $methodNames, 831 | Scope $scope 832 | ): array 833 | { 834 | $calledOnType = $this->getClassType($class, $scope); 835 | if ($calledOnType === null) { 836 | return []; 837 | } 838 | 839 | $methodReflections = []; 840 | $classNames = TypeUtils::getDirectClassNames($calledOnType); 841 | foreach ($classNames as $className) { 842 | try { 843 | $classReflection = $this->broker->getClass($className); 844 | } catch (ClassNotFoundException $e) { 845 | continue; 846 | } 847 | 848 | foreach ($methodNames as $methodName) { 849 | try { 850 | $methodReflections[] = $classReflection->getMethod($methodName, $scope); 851 | } catch (MissingMethodFromReflectionException $e) { 852 | continue; 853 | } 854 | } 855 | } 856 | 857 | return $methodReflections; 858 | } 859 | 860 | /** 861 | * @param Name|Expr|ClassLike $class 862 | */ 863 | private function getClassType($class, Scope $scope): ?Type 864 | { 865 | if ($class instanceof ClassLike) { 866 | $className = $class->name; 867 | if ($className === null) { 868 | return null; 869 | } 870 | 871 | return new ObjectType($className->name); 872 | 873 | } 874 | 875 | if ($class instanceof Name) { 876 | return new ObjectType($scope->resolveName($class)); 877 | } 878 | 879 | return $scope->getType($class); 880 | } 881 | 882 | } 883 | --------------------------------------------------------------------------------