Passed
Push — main ( ecc027...46eee6 )
by mikhail
03:29
created

UncaughtExceptionVisitor::isExceptionCaught()   A

Complexity

Conditions 6
Paths 6

Size

Total Lines 21
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 11
dl 0
loc 21
ccs 11
cts 11
cp 1
rs 9.2222
c 1
b 0
f 0
cc 6
nc 6
nop 1
crap 6
1
<?php
2
3
declare(strict_types=1);
4
5
namespace SavinMikhail\CommentsDensity\MissingDocblock\Visitors;
6
7
use phpDocumentor\Reflection\DocBlockFactory;
8
use PhpParser\Node;
9
use PhpParser\Node\Expr\Assign;
10
use PhpParser\Node\Expr\MethodCall;
11
use PhpParser\Node\Expr\New_;
12
use PhpParser\Node\Expr\Throw_;
13
use PhpParser\Node\Expr\Variable;
14
use PhpParser\Node\Stmt\Catch_;
15
use PhpParser\Node\Stmt\Class_;
16
use PhpParser\Node\Stmt\ClassMethod;
17
use PhpParser\Node\Stmt\Expression;
18
use PhpParser\Node\Stmt\TryCatch;
19
use PhpParser\NodeVisitorAbstract;
20
use ReflectionClass;
21
22
use function array_pop;
23
use function class_exists;
24
25
final class UncaughtExceptionVisitor extends NodeVisitorAbstract
26
{
27
    public bool $hasUncaughtThrows = false;
28
    /**
29
     * @var TryCatch[]
30
     */
31
    private array $tryCatchStack = [];
32
33
    private array $variableTypes = [];
34
35 39
    public function __construct(private readonly ?Class_ $class)
36
    {
37 39
    }
38
39 32
    private function registerClassMethod(ClassMethod $node): void
40
    {
41 32
        if ($this->class) {
42 32
            $className = $this->class->namespacedName->toString();
0 ignored issues
show
Bug introduced by
The method toString() does not exist on null. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

42
            /** @scrutinizer ignore-call */ 
43
            $className = $this->class->namespacedName->toString();

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
43 32
            $this->variableTypes['this'] = $className;
44
        }
45 32
        $stmts = $node->getStmts();
46 32
        if (!$stmts) {
47 2
            return;
48
        }
49 30
        foreach ($stmts as $stmt) {
50 30
            if (!($stmt instanceof Expression && $stmt->expr instanceof Assign)) {
51 30
                continue;
52
            }
53 6
            $var = $stmt->expr->var;
54 6
            $expr = $stmt->expr->expr;
55 6
            if ($var instanceof Variable && $expr instanceof New_) {
56 6
                $this->variableTypes[$var->name] = $expr->class->name;
0 ignored issues
show
Bug introduced by
Accessing name on the interface PhpParser\Node suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
57
            }
58
        }
59
    }
60
61 10
    private function checkIfExceptionIsCaught(Throw_ $node): void
62
    {
63 10
        if (!$this->isInTryBlock($node)) {
64 4
            $this->hasUncaughtThrows = true;
65 7
        } elseif (!$this->isExceptionCaught($node)) {
66 2
            $this->hasUncaughtThrows = true;
67 5
        } elseif ($this->isInCatchBlock($node) && !$this->isRethrowingCaughtException($node)) {
68
            $this->hasUncaughtThrows = true;
69
        }
70
    }
71
72 7
    private function checkMethodCallForThrowingUncaughtException(MethodCall $node): void
73
    {
74 7
        $methodName = $node->name->name;
0 ignored issues
show
Bug introduced by
Accessing name on the interface PhpParser\Node suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
75 7
        if (!isset($node->var->name)) {
76
            return;
77
        }
78
79 7
        $objectName = (string) $node->var->name;
80
81 7
        $class = $this->variableTypes[$objectName] ?? null;
82
83 7
        if (!$class) {
84 2
            return;
85
        }
86
87 5
        $exceptions = $this->getMethodThrownExceptions($class, $methodName);
88 5
        foreach ($exceptions as $exception) {
89 4
            $throwNode = new Throw_(new Variable($exception), $node->getAttributes());
90 4
            $this->checkIfExceptionIsCaught($throwNode);
91
        }
92
    }
93
94 39
    public function enterNode(Node $node): ?Node
95
    {
96 39
        if ($node instanceof ClassMethod) {
97 32
            $this->registerClassMethod($node);
98
        }
99
100 39
        if ($node instanceof TryCatch) {
101 7
            $this->tryCatchStack[] = $node;
102
        }
103
104 39
        if ($node instanceof Throw_) {
105 7
            $this->checkIfExceptionIsCaught($node);
106
        }
107
108 39
        if ($node instanceof MethodCall) {
109 7
            $this->checkMethodCallForThrowingUncaughtException($node);
110
        }
111
112 39
        return null;
113
    }
114
115 5
    private function getMethodThrownExceptions(string $className, string $methodName): array
116
    {
117 5
        if (!class_exists($className)) {
118
            return [];
119
        }
120
121 5
        $reflectionClass = new ReflectionClass($className);
122 5
        if (!$reflectionClass->hasMethod($methodName)) {
123
            return [];
124
        }
125
126 5
        $reflectionMethod = $reflectionClass->getMethod($methodName);
127 5
        $docComment = $reflectionMethod->getDocComment();
128
129 5
        if (!$docComment) {
130
            return [];
131
        }
132
133 5
        $docBlockFactory = DocBlockFactory::createInstance();
134 5
        $docBlock = $docBlockFactory->create($docComment);
135
136 5
        $exceptions = [];
137 5
        foreach ($docBlock->getTagsByName('throws') as $tag) {
138 4
            $exceptions[] = (string)$tag->getType();
139
        }
140
141 5
        return $exceptions;
142
    }
143
144 39
    public function leaveNode(Node $node): ?Node
145
    {
146 39
        if ($node instanceof TryCatch) {
147 7
            array_pop($this->tryCatchStack);
148
        }
149
150 39
        return null;
151
    }
152
153 5
    private function isInCatchBlock(Throw_ $node): bool
154
    {
155 5
        foreach ($this->getCurrentCatchStack() as $catch) {
156 5
            if ($this->nodeIsWithin($node, $catch)) {
157
                return true;
158
            }
159
        }
160 5
        return false;
161
    }
162
163 10
    private function isInTryBlock(Throw_ $node): bool
164
    {
165 10
        foreach ($this->tryCatchStack as $tryCatch) {
166 7
            foreach ($tryCatch->stmts as $stmt) {
167 7
                if ($this->nodeIsWithin($node, $stmt)) {
168 7
                    return true;
169
                }
170
            }
171
        }
172 4
        return false;
173
    }
174
175
    private function isRethrowingCaughtException(Throw_ $throwNode): bool
176
    {
177
        $throwExpr = $throwNode->expr;
178
179
        if (!$throwExpr instanceof Variable) {
180
            return false;
181
        }
182
183
        foreach ($this->getCurrentCatchStack() as $catch) {
184
            if ($catch->var instanceof Variable && $catch->var->name === $throwExpr->name) {
185
                return true;
186
            }
187
        }
188
189
        return false;
190
    }
191
192 7
    private function nodeIsWithin(Node $node, Node $container): bool
193
    {
194 7
        return $node->getStartFilePos() >= $container->getStartFilePos() &&
195 7
            $node->getEndFilePos() <= $container->getEndFilePos();
196
    }
197
198 7
    private function getExceptionFQN(Throw_ $throwNode): string
199
    {
200 7
        $throwExpr = $throwNode->expr;
201
202 7
        if ($throwExpr instanceof Variable) {
203 2
            $thrownExceptionType = $throwExpr->name;
204 5
        } elseif ($throwExpr instanceof New_) {
205 5
            $thrownExceptionType = $throwExpr->class->name;
0 ignored issues
show
Bug introduced by
Accessing name on the interface PhpParser\Node suggest that you code against a concrete implementation. How about adding an instanceof check?
Loading history...
206
        }
207
208 7
        if ($thrownExceptionType[0] !== '\\') {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $thrownExceptionType does not seem to be defined for all execution paths leading up to this point.
Loading history...
209 5
            $thrownExceptionType = '\\' . $thrownExceptionType;
210
        }
211
212 7
        return $thrownExceptionType;
213
    }
214
215 7
    private function isExceptionCaught(Throw_ $throwNode): bool
216
    {
217 7
        $thrownExceptionType = $this->getExceptionFQN($throwNode);
218
219 7
        foreach ($this->getCurrentCatchStack() as $catch) {
220 7
            foreach ($catch->types as $catchType) {
221 7
                $catchTypeName = $catchType->name;
222 7
                if (!$catchType->isQualified()) {
223 7
                    $catchTypeName = '\\' . $catchTypeName;
224
                }
225
226
                if (
227 7
                    $this->isSubclassOf($thrownExceptionType, $catchTypeName)
228 7
                    || $catchType->name === 'Throwable'
229
                ) {
230 5
                    return true;
231
                }
232
            }
233
        }
234
235 2
        return false;
236
    }
237
238
    /** @return Catch_[] */
239 7
    private function getCurrentCatchStack(): array
240
    {
241 7
        $catchStack = [];
242 7
        foreach ($this->tryCatchStack as $tryCatch) {
243 7
            $catchStack = array_merge($catchStack, $tryCatch->catches);
244
        }
245 7
        return $catchStack;
246
    }
247
248 7
    private function isSubclassOf(?string $className, string $parentClassName): bool
249
    {
250 7
        if ($className === null) {
251
            return false;
252
        }
253
254 7
        if (!class_exists($className) || !class_exists($parentClassName)) {
255 3
            return false;
256
        }
257
258 6
        $reflectionClass = new ReflectionClass($className);
259 6
        return $reflectionClass->isSubclassOf($parentClassName) || $className === $parentClassName;
260
    }
261
}
262