diff --git a/Makefile b/Makefile index a0b15cef22e..e087e174925 100644 --- a/Makefile +++ b/Makefile @@ -42,6 +42,9 @@ lint: --exclude tests/PHPStan/Levels/data/namedArguments.php \ --exclude tests/PHPStan/Rules/Keywords/data/continue-break.php \ --exclude tests/PHPStan/Rules/Keywords/data/continue-break-property-hook.php \ + --exclude tests/PHPStan/Rules/Keywords/data/goto-undefined-label.php \ + --exclude tests/PHPStan/Rules/Keywords/data/goto-undefined-label-property-hook.php \ + --exclude tests/PHPStan/Rules/Keywords/data/unused-label-property-hook.php \ --exclude tests/PHPStan/Rules/Keywords/data/bug-13790-break.php \ --exclude tests/PHPStan/Rules/Keywords/data/bug-13790-continue.php \ --exclude tests/PHPStan/Rules/Properties/data/invalid-callable-property-type.php \ diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index ccb34eea665..528084e3532 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -19,3 +19,4 @@ parameters: checkDateIntervalConstructor: true reportMethodPurityOverride: true checkDynamicConstantNameValues: true + unusedLabel: true diff --git a/conf/config.level4.neon b/conf/config.level4.neon index 7bab495c842..cd0b60d4c16 100644 --- a/conf/config.level4.neon +++ b/conf/config.level4.neon @@ -12,6 +12,8 @@ conditionalTags: phpstan.rules.rule: %exceptions.check.tooWideThrowType% PHPStan\Rules\Exceptions\TooWidePropertyHookThrowTypeRule: phpstan.rules.rule: %exceptions.check.tooWideThrowType% + PHPStan\Rules\Keywords\UnusedLabelRule: + phpstan.rules.rule: %featureToggles.unusedLabel% parameters: checkAdvancedIsset: true @@ -30,3 +32,6 @@ services: class: PHPStan\Rules\Exceptions\TooWidePropertyHookThrowTypeRule arguments: checkProtectedAndPublicMethods: %checkTooWideThrowTypesInProtectedAndPublicMethods% + + - + class: PHPStan\Rules\Keywords\UnusedLabelRule diff --git a/conf/config.neon b/conf/config.neon index f0ae51d29dc..3823fc7d9f4 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -46,6 +46,7 @@ parameters: checkDateIntervalConstructor: false reportMethodPurityOverride: false checkDynamicConstantNameValues: false + unusedLabel: false fileExtensions: - php checkAdvancedIsset: false diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 46f7f6a04e0..72e830010c6 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -48,6 +48,7 @@ parametersSchema: checkDateIntervalConstructor: bool() reportMethodPurityOverride: bool() checkDynamicConstantNameValues: bool() + unusedLabel: bool() ]) fileExtensions: listOf(string()) checkAdvancedIsset: bool() diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 1f0b7a4cb09..d013b03359a 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -38,6 +38,7 @@ use PhpParser\Node\Stmt\Echo_; use PhpParser\Node\Stmt\For_; use PhpParser\Node\Stmt\Foreach_; +use PhpParser\Node\Stmt\Goto_; use PhpParser\Node\Stmt\If_; use PhpParser\Node\Stmt\InlineHTML; use PhpParser\Node\Stmt\Return_; @@ -110,6 +111,7 @@ use PHPStan\Node\VarTagChangedExpressionTypeNode; use PHPStan\Parser\ArrowFunctionArgVisitor; use PHPStan\Parser\ClosureArgVisitor; +use PHPStan\Parser\GotoLabelVisitor; use PHPStan\Parser\ImmediatelyInvokedClosureVisitor; use PHPStan\Parser\LineAttributesVisitor; use PHPStan\Parser\Parser; @@ -408,12 +410,59 @@ private function processStmtNodesInternalWithoutFlushingPendingFibers( || $parentNode instanceof Node\Stmt\ClassMethod || $parentNode instanceof PropertyHookStatementNode || $parentNode instanceof Expr\Closure; + foreach ($stmts as $i => $stmt) { - if ($alreadyTerminated && !($stmt instanceof Node\Stmt\Function_ || $stmt instanceof Node\Stmt\ClassLike)) { + if ($alreadyTerminated && !($stmt instanceof Node\Stmt\Function_ || $stmt instanceof Node\Stmt\ClassLike || $stmt instanceof Node\Stmt\Label)) { continue; } $isLast = $i === $stmtCount - 1; + + $nestedLabelNames = $stmt->getAttribute(GotoLabelVisitor::NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE); + if ($nestedLabelNames !== null && $context->isTopLevel()) { + $originalStorage = $storage; + $bodyScope = $scope; + $count = 0; + do { + $prevScope = $bodyScope; + $tempStorage = $originalStorage->duplicate(); + $bodyScopeResult = $this->processStmtNodesInternal( + $parentNode, + [$stmt], + $bodyScope, + $tempStorage, + new NoopNodeCallback(), + $context->enterDeep(), + ); + + $gotoScope = null; + foreach ($bodyScopeResult->getExitPoints() as $ep) { + $epStmt = $ep->getStatement(); + if (!($epStmt instanceof Goto_) || !isset($nestedLabelNames[$epStmt->name->toString()])) { + continue; + } + + $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); + } + + if ($gotoScope !== null) { + $bodyScope = $scope->mergeWith($gotoScope); + } + + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + $scope = $bodyScope; + $storage = $originalStorage; + } + $statementResult = $this->processStmtNode( $stmt, $scope, @@ -424,6 +473,76 @@ private function processStmtNodesInternalWithoutFlushingPendingFibers( $scope = $statementResult->getScope(); $hasYield = $hasYield || $statementResult->hasYield(); + if ($stmt instanceof Node\Stmt\Label) { + $labelName = $stmt->name->toString(); + + $newExitPoints = []; + foreach ($exitPoints as $exitPoint) { + $exitStmt = $exitPoint->getStatement(); + if ($exitStmt instanceof Goto_ && $exitStmt->name->toString() === $labelName) { + if ($alreadyTerminated) { + $scope = $exitPoint->getScope(); + $alreadyTerminated = false; + } else { + $scope = $scope->mergeWith($exitPoint->getScope()); + } + } else { + $newExitPoints[] = $exitPoint; + } + } + $exitPoints = $newExitPoints; + + if ($alreadyTerminated) { + continue; + } + + if ($stmt->getAttribute(GotoLabelVisitor::HAS_BACKWARD_GOTO_ATTRIBUTE) === true && $context->isTopLevel()) { + $originalStorage = $storage; + $bodyStmts = array_slice($stmts, $i + 1); + $bodyScope = $scope; + $count = 0; + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + $tempStorage = $originalStorage->duplicate(); + $bodyScopeResult = $this->processStmtNodesInternal( + $parentNode, + $bodyStmts, + $bodyScope, + $tempStorage, + new NoopNodeCallback(), + $context->enterDeep(), + ); + + $gotoScope = null; + foreach ($bodyScopeResult->getExitPoints() as $ep) { + $epStmt = $ep->getStatement(); + if (!($epStmt instanceof Goto_) || $epStmt->name->toString() !== $labelName) { + continue; + } + + $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); + } + + if ($gotoScope !== null) { + $bodyScope = $scope->mergeWith($gotoScope); + } + + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + + $scope = $bodyScope; + $storage = $originalStorage; + } + } + if ($shouldCheckLastStatement && $isLast) { $endStatements = $statementResult->getEndStatements(); if (count($endStatements) > 0) { @@ -917,6 +1036,18 @@ public function processStmtNode( return new InternalStatementResult($scope, $hasYield, true, [ new InternalStatementExitPoint($stmt, $scope), ], $overridingThrowPoints ?? $throwPoints, $impurePoints); + } elseif ($stmt instanceof Goto_) { + $hasYield = false; + $throwPoints = []; + $impurePoints = []; + + return new InternalStatementResult($scope, $hasYield, true, [ + new InternalStatementExitPoint($stmt, $scope), + ], $overridingThrowPoints ?? $throwPoints, $impurePoints); + } elseif ($stmt instanceof Node\Stmt\Label) { + $hasYield = false; + $throwPoints = $overridingThrowPoints ?? []; + $impurePoints = []; } elseif ($stmt instanceof Node\Stmt\Expression) { if ($stmt->expr instanceof Expr\Throw_) { $scope = $stmtScope; @@ -4800,6 +4931,9 @@ private function getNextUnreachableStatements(array $nodes, bool $earlyBinding): $stmts = []; $isPassedUnreachableStatement = false; foreach ($nodes as $node) { + if ($node instanceof Node\Stmt\Label) { + break; + } if ($earlyBinding && ($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\HaltCompiler)) { continue; } diff --git a/src/Parser/GotoLabelVisitor.php b/src/Parser/GotoLabelVisitor.php new file mode 100644 index 00000000000..efb620cfbd6 --- /dev/null +++ b/src/Parser/GotoLabelVisitor.php @@ -0,0 +1,281 @@ +, gotos: list}> */ + private array $scopeStack = []; + + /** @var array, gotos: array}> */ + private array $subtreeData = []; + + private bool $hasGotoOrLabel = false; + + #[Override] + public function beforeTraverse(array $nodes): ?array + { + $this->scopeStack = []; + $this->subtreeData = []; + $this->hasGotoOrLabel = false; + $this->pushScope(); + return null; + } + + #[Override] + public function afterTraverse(array $nodes): ?array + { + $this->popScope(); + $this->subtreeData = []; + return null; + } + + #[Override] + public function enterNode(Node $node): ?Node + { + if ($node instanceof Node\Stmt\Label) { + $this->hasGotoOrLabel = true; + $scopeIndex = count($this->scopeStack) - 1; + $this->scopeStack[$scopeIndex]['labels'][$node->name->toString()] = $node; + return null; + } + + if ($node instanceof Goto_) { + $this->hasGotoOrLabel = true; + $scopeIndex = count($this->scopeStack) - 1; + $this->scopeStack[$scopeIndex]['gotos'][] = $node; + return null; + } + + if ($this->isScopeBoundary($node)) { + $this->pushScope(); + } + + return null; + } + + #[Override] + public function leaveNode(Node $node): ?Node + { + if (!$this->hasGotoOrLabel) { + if ($this->isScopeBoundary($node)) { + $this->popScope(); + } + return null; + } + + $id = spl_object_id($node); + + if ($node instanceof Node\Stmt\Label) { + $this->subtreeData[$id] = ['labels' => [$node->name->toString() => true], 'gotos' => []]; + return null; + } + + if ($node instanceof Goto_) { + $this->subtreeData[$id] = ['labels' => [], 'gotos' => [$node->name->toString() => true]]; + return null; + } + + $stmts = $this->getStmts($node); + if ($stmts !== null) { + $this->processStatementList($stmts); + } + + $labels = []; + $gotos = []; + $childIds = []; + foreach ($node->getSubNodeNames() as $name) { + $sub = $node->{$name}; + if ($sub instanceof Node) { + if (!$this->isScopeBoundary($sub)) { + $childId = spl_object_id($sub); + if (isset($this->subtreeData[$childId])) { + $labels += $this->subtreeData[$childId]['labels']; + $gotos += $this->subtreeData[$childId]['gotos']; + $childIds[] = $childId; + } + } + } elseif (is_array($sub)) { + foreach ($sub as $subItem) { + if (!$subItem instanceof Node) { + continue; + } + if ($this->isScopeBoundary($subItem)) { + continue; + } + $childId = spl_object_id($subItem); + if (!isset($this->subtreeData[$childId])) { + continue; + } + $labels += $this->subtreeData[$childId]['labels']; + $gotos += $this->subtreeData[$childId]['gotos']; + $childIds[] = $childId; + } + } + } + + foreach ($childIds as $childId) { + unset($this->subtreeData[$childId]); + } + + if ($labels !== [] || $gotos !== []) { + $this->subtreeData[$id] = ['labels' => $labels, 'gotos' => $gotos]; + } + + if ($this->isScopeBoundary($node)) { + $this->popScope(); + } + + return null; + } + + /** + * @param array $stmts + */ + private function processStatementList(array $stmts): void + { + $labelIndices = []; + foreach ($stmts as $idx => $s) { + if (!($s instanceof Node\Stmt\Label)) { + continue; + } + + $labelIndices[$s->name->toString()] = $idx; + } + + $stmtCount = count($stmts); + + if ($labelIndices !== []) { + foreach ($labelIndices as $labelName => $labelIdx) { + for ($j = $labelIdx + 1; $j < $stmtCount; $j++) { + $childId = spl_object_id($stmts[$j]); + if (isset($this->subtreeData[$childId]['gotos'][$labelName])) { + $stmts[$labelIdx]->setAttribute(self::HAS_BACKWARD_GOTO_ATTRIBUTE, true); + break; + } + } + } + } + + foreach ($stmts as $s) { + if ($s instanceof Node\Stmt\Label) { + continue; + } + $childId = spl_object_id($s); + if (!isset($this->subtreeData[$childId])) { + continue; + } + $childData = $this->subtreeData[$childId]; + if ($childData['labels'] === [] || $childData['gotos'] === []) { + continue; + } + $matchedLabels = array_intersect_key($childData['gotos'], $childData['labels']); + if ($matchedLabels === []) { + continue; + } + + $s->setAttribute(self::NESTED_BACKWARD_GOTO_LABELS_ATTRIBUTE, $matchedLabels); + } + } + + /** + * @return array|null + */ + private function getStmts(Node $node): ?array + { + if ($node instanceof Node\PropertyHook) { + return is_array($node->body) ? $node->body : null; + } + + if ($node instanceof Node\Stmt\ClassLike) { + return null; + } + + if ( + $node instanceof Node\Stmt\Function_ + || $node instanceof Node\Stmt\ClassMethod + || $node instanceof Node\Expr\Closure + || $node instanceof Node\Stmt\If_ + || $node instanceof Node\Stmt\ElseIf_ + || $node instanceof Node\Stmt\Else_ + || $node instanceof Node\Stmt\Case_ + || $node instanceof Node\Stmt\Catch_ + || $node instanceof Node\Stmt\Do_ + || $node instanceof Node\Stmt\Finally_ + || $node instanceof Node\Stmt\For_ + || $node instanceof Node\Stmt\Foreach_ + || $node instanceof Node\Stmt\Namespace_ + || $node instanceof Node\Stmt\TryCatch + || $node instanceof Node\Stmt\While_ + || $node instanceof Node\Stmt\Block + || $node instanceof Node\Stmt\Declare_ + ) { + return $node->stmts ?? null; + } + + return null; + } + + private function isScopeBoundary(Node $node): bool + { + if ( + $node instanceof Node\Stmt\Function_ + || $node instanceof Node\Stmt\ClassMethod + || $node instanceof Node\Expr\Closure + || $node instanceof Node\Stmt\ClassLike + ) { + return true; + } + + return $node instanceof Node\PropertyHook && is_array($node->body); + } + + private function pushScope(): void + { + $this->scopeStack[] = ['labels' => [], 'gotos' => []]; + } + + private function popScope(): void + { + $frame = array_pop($this->scopeStack); + if ($frame === null) { + return; + } + + $usedLabelNames = []; + foreach ($frame['gotos'] as $goto) { + $gotoName = $goto->name->toString(); + if (!isset($frame['labels'][$gotoName])) { + $goto->setAttribute(self::GOTO_LABEL_UNDEFINED_ATTRIBUTE, true); + } else { + $usedLabelNames[$gotoName] = true; + } + } + + foreach ($frame['labels'] as $name => $label) { + $label->setAttribute(self::LABEL_IS_USED_ATTRIBUTE, isset($usedLabelNames[$name])); + } + } + +} diff --git a/src/Rules/Keywords/GotoUndefinedLabelRule.php b/src/Rules/Keywords/GotoUndefinedLabelRule.php new file mode 100644 index 00000000000..cbd2f7ab523 --- /dev/null +++ b/src/Rules/Keywords/GotoUndefinedLabelRule.php @@ -0,0 +1,43 @@ + + */ +#[RegisteredRule(level: 0)] +final class GotoUndefinedLabelRule implements Rule +{ + + public function getNodeType(): string + { + return Goto_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->getAttribute(GotoLabelVisitor::GOTO_LABEL_UNDEFINED_ATTRIBUTE) !== true) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + "Goto to undefined label '%s'.", + $node->name->toString(), + )) + ->nonIgnorable() + ->identifier('goto.labelUndefined') + ->build(), + ]; + } + +} diff --git a/src/Rules/Keywords/UnusedLabelRule.php b/src/Rules/Keywords/UnusedLabelRule.php new file mode 100644 index 00000000000..5a8d9551fa6 --- /dev/null +++ b/src/Rules/Keywords/UnusedLabelRule.php @@ -0,0 +1,39 @@ + + */ +final class UnusedLabelRule implements Rule +{ + + public function getNodeType(): string + { + return Node\Stmt\Label::class; + } + + public function processNode(Node $node, Scope $scope): array + { + if ($node->getAttribute(GotoLabelVisitor::LABEL_IS_USED_ATTRIBUTE) !== false) { + return []; + } + + return [ + RuleErrorBuilder::message(sprintf( + "Label '%s' is unused.", + $node->name->toString(), + )) + ->identifier('label.unused') + ->build(), + ]; + } + +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-4674.php b/tests/PHPStan/Analyser/nsrt/bug-4674.php new file mode 100644 index 00000000000..86794b4367c --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-4674.php @@ -0,0 +1,34 @@ +', $data); + foreach ($data as $item) { + assertType('int', $item); + echo $item; + } +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-7734.php b/tests/PHPStan/Analyser/nsrt/bug-7734.php new file mode 100644 index 00000000000..6e9eb1e0b68 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7734.php @@ -0,0 +1,18 @@ +', $y); + + foreach ($y as $content) { + $content = json_encode($content); + if ($content !== false) { + $data[] = $content; + } + } + + ret: + return $data; +} + +/** + * @return null|array + */ +function Y(): ?array +{ + $x = '["a", "b", "c"]'; + $y = json_decode($x, true); + $z = []; + + if (json_last_error() !== JSON_ERROR_NONE) { + return null; + } + + foreach($y as $letter) { + $num = ord($letter); + if ($num % 2 === 0) { + $z[] = null; + } else { + $z[] = new \stdClass(); + } + } + + return $z; +} diff --git a/tests/PHPStan/Analyser/nsrt/goto-label-stabilization.php b/tests/PHPStan/Analyser/nsrt/goto-label-stabilization.php new file mode 100644 index 00000000000..808fc6e3c7e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/goto-label-stabilization.php @@ -0,0 +1,171 @@ +', $i); + $i++; + if ($i < 10) { + goto start; + } + assertType('int<10, max>', $i); +} + +function backwardGotoNullCheck(): void +{ + retry: + $result = rand(0, 1) ? 'value' : null; + if ($result === null) { + goto retry; + } + assertType("'value'", $result); +} + +function forwardGotoSkipsCode(): void +{ + /** @var int|string $x */ + $x = doSomething(); + if (is_int($x)) { + goto skip; + } + assertType('string', $x); + skip: + assertType('int|string', $x); +} + +/** @return int|string */ +function doSomething() +{ + return rand(0, 1) ? 1 : 'a'; +} + +function gotoOutOfIf(): void +{ + /** @var int|null $val */ + $val = rand(0, 1) ? 42 : null; + if ($val === null) { + goto handleNull; + } + + assertType('int', $val); + echo $val * 2; + goto done; + + handleNull: + assertType('null', $val); + echo "null value"; + + done: + assertType('int|null', $val); +} + +function multipleGotosToSameLabel(): void +{ + /** @var int|string|null $x */ + $x = doSomething2(); + + if ($x === null) { + goto end; + } + if (is_string($x)) { + goto end; + } + + assertType('int', $x); + + end: + assertType('int|string|null', $x); +} + +/** @return int|string|null */ +function doSomething2() +{ + return rand(0, 2) === 0 ? null : (rand(0, 1) ? 1 : 'a'); +} + +function retryPatternWithCounter(): void +{ + $attempt = 0; + + retry: + $attempt++; + assertType('int<1, max>', $attempt); + + if (rand(0, 1) === 1 && $attempt < 3) { + goto retry; + } + + assertType('int<1, max>', $attempt); +} + +function closureGotoDoesNotAffectOuterLabel(): void +{ + $x = 0; + start: + $x++; + + $fn = function () { + start: + $inner = rand(0, 1) ? 'world' : null; + if ($inner === null) { + goto start; + } + }; + + assertType('1', $x); +} + +function anonymousClassGotoDoesNotAffectOuterLabel(): void +{ + $x = 0; + start: + $x++; + + $obj = new class { + public function doSomething(): void + { + start: + $inner = rand(0, 1) ? 'world' : null; + if ($inner === null) { + goto start; + } + } + }; + + assertType('1', $x); +} + +function nestedFunctionGotoDoesNotAffectOuterLabel(): void +{ + $x = 0; + start: + $x++; + + function innerFunction(): void + { + start: + $inner = rand(0, 1) ? 'world' : null; + if ($inner === null) { + goto start; + } + } + + assertType('1', $x); +} diff --git a/tests/PHPStan/Analyser/nsrt/goto-property-hook.php b/tests/PHPStan/Analyser/nsrt/goto-property-hook.php new file mode 100644 index 00000000000..85ae2a6dbef --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/goto-property-hook.php @@ -0,0 +1,67 @@ += 8.4 + +declare(strict_types = 1); + +namespace GotoPropertyHook; + +use function PHPStan\Testing\assertType; + +class BackwardGotoInHook +{ + + public int $value { + get { + $i = 0; + retry: + $i++; + $val = rand(0, 1) ? 42 : null; + if ($val === null) { + goto retry; + } + assertType('int<1, max>', $i); + return $val; + } + } + +} + +class ForwardGotoInHook +{ + + public int $value { + get { + $a = rand(0, 1) ? 42 : false; + if ($a === false) { + goto fallback; + } + + assertType('42', $a); + return $a; + + fallback: + assertType('false', $a); + return 0; + } + } + +} + +class ForwardGotoFallThroughInHook +{ + + public int $value { + get { + $a = rand(0, 1) ? 42 : false; + if ($a === false) { + goto fallback; + } + + assertType('42', $a); + + fallback: + assertType('42|false', $a); + return $a !== false ? $a : 0; + } + } + +} diff --git a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php index 98f3f3e95af..fb5c504a94b 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php @@ -234,4 +234,15 @@ public function testBug6702(): void $this->analyse([__DIR__ . '/data/bug-6702.php'], []); } + public function testBug12852(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-12852.php'], [ + [ + 'Negated boolean expression is always true.', + 10, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php index 48dce91125c..0d96baee651 100644 --- a/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php @@ -313,4 +313,9 @@ public function testBug3831(): void $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-3831.php'], []); } + public function testBug12167(): void + { + $this->analyse([__DIR__ . '/data/bug-12167.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-12167.php b/tests/PHPStan/Rules/Comparison/data/bug-12167.php new file mode 100644 index 00000000000..67b3cb51540 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-12167.php @@ -0,0 +1,21 @@ +analyse([__DIR__ . '/data/bug-14582.php'], []); } + public function testBug11731(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-11731.php'], [ + [ + 'Unreachable statement - code above always terminates.', + 9, + ], + [ + 'Unreachable statement - code above always terminates.', + 19, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/DeadCode/data/bug-11731.php b/tests/PHPStan/Rules/DeadCode/data/bug-11731.php new file mode 100644 index 00000000000..ee0fa8b2d0d --- /dev/null +++ b/tests/PHPStan/Rules/DeadCode/data/bug-11731.php @@ -0,0 +1,23 @@ + + */ +class GotoUndefinedLabelRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new GotoUndefinedLabelRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/goto-undefined-label.php'], [ + [ + "Goto to undefined label 'nonexistent'.", + 15, + ], + [ + "Goto to undefined label 'outside'.", + 22, + ], + [ + "Goto to undefined label 'outside'.", + 32, + ], + [ + "Goto to undefined label 'outside'.", + 42, + ], + [ + "Goto to undefined label 'inside'.", + 52, + ], + ]); + } + + #[RequiresPhp('>= 8.4.0')] + public function testPropertyHook(): void + { + $this->analyse([__DIR__ . '/data/goto-undefined-label-property-hook.php'], [ + [ + "Goto to undefined label 'nonexistent'.", + 21, + ], + [ + "Goto to undefined label 'outside'.", + 35, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Keywords/UnusedLabelRuleTest.php b/tests/PHPStan/Rules/Keywords/UnusedLabelRuleTest.php new file mode 100644 index 00000000000..599a94c305d --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/UnusedLabelRuleTest.php @@ -0,0 +1,53 @@ + + */ +class UnusedLabelRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new UnusedLabelRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/unused-label.php'], [ + [ + "Label 'unused' is unused.", + 15, + ], + [ + "Label 'unused1' is unused.", + 31, + ], + [ + "Label 'unused2' is unused.", + 35, + ], + [ + "Label 'outside' is unused.", + 41, + ], + ]); + } + + #[RequiresPhp('>= 8.4.0')] + public function testPropertyHook(): void + { + $this->analyse([__DIR__ . '/data/unused-label-property-hook.php'], [ + [ + "Label 'unused' is unused.", + 21, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Keywords/data/goto-undefined-label-property-hook.php b/tests/PHPStan/Rules/Keywords/data/goto-undefined-label-property-hook.php new file mode 100644 index 00000000000..872b088f8eb --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/goto-undefined-label-property-hook.php @@ -0,0 +1,41 @@ += 8.4 + +declare(strict_types = 1); + +namespace GotoUndefinedLabelPropertyHook; + +class Foo +{ + + public int $value { + get { + goto end; + echo "unreachable"; + end: + return 42; + } + } + + public int $broken { + get { + goto nonexistent; + return 0; + } + } + +} + +class CrossBoundary +{ + + public int $value { + get { + outside: + $fn = function () { + goto outside; + }; + return 42; + } + } + +} diff --git a/tests/PHPStan/Rules/Keywords/data/goto-undefined-label.php b/tests/PHPStan/Rules/Keywords/data/goto-undefined-label.php new file mode 100644 index 00000000000..eab9366eeca --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/goto-undefined-label.php @@ -0,0 +1,85 @@ += 8.4 + +declare(strict_types = 1); + +namespace UnusedLabelPropertyHook; + +class Foo +{ + + public int $value { + get { + goto end; + echo "unreachable"; + end: + return 42; + } + } + + public int $broken { + get { + unused: + return 0; + } + } + +} diff --git a/tests/PHPStan/Rules/Keywords/data/unused-label.php b/tests/PHPStan/Rules/Keywords/data/unused-label.php new file mode 100644 index 00000000000..91c35e61275 --- /dev/null +++ b/tests/PHPStan/Rules/Keywords/data/unused-label.php @@ -0,0 +1,58 @@ +analyse([__DIR__ . '/data/bug-12722.php'], []); } + #[RequiresPhp('>= 8.1')] + public function testBug14638(): void + { + $this->checkExplicitMixedMissingReturn = true; + $this->analyse([__DIR__ . '/data/bug-14638.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Missing/data/bug-14638.php b/tests/PHPStan/Rules/Missing/data/bug-14638.php new file mode 100644 index 00000000000..0b5122572d1 --- /dev/null +++ b/tests/PHPStan/Rules/Missing/data/bug-14638.php @@ -0,0 +1,92 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14638; + +interface CachedValueInterface +{ + public function getValue(): mixed; +} + +interface CacheItemInterface {} + +interface AdapterInterface {} + +interface ItemInterface extends CacheItemInterface {} + +/** + * @template T + */ +interface CallbackInterface +{ + /** + * @return T + */ + public function __invoke(CacheItemInterface $item, bool &$save): mixed; +} + +interface CacheInterface +{ + /** + * @template T + * + * @param (callable(CacheItemInterface,bool):T)|(callable(ItemInterface,bool):T)|CallbackInterface $callback + * + * @return T + */ + public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed; +} + +class PhpArrayAdapter implements CacheInterface +{ + /** @var array */ + private array $keys; + /** @var array */ + private array $values; + + public function __construct(private readonly AdapterInterface $pool) {} + + public function get(string $key, callable $callback, ?float $beta = null, ?array &$metadata = null): mixed + { + if (!isset($this->values)) { + $this->initialize(); + } + if (!isset($this->keys[$key])) { + get_from_pool: + if ($this->pool instanceof CacheInterface) { + return $this->pool->get($key, $callback, $beta, $metadata); + } + + return $this->doGet($this->pool, $key, $callback, $beta, $metadata); + } + $value = $this->values[$this->keys[$key]]; + + if ('N;' === $value) { + return null; + } + if (!$value instanceof CachedValueInterface) { + return $value; + } + try { + return $value->getValue(); + } catch (\Throwable) { + unset($this->keys[$key]); + goto get_from_pool; + } + } + + private function initialize(): void + { + $this->keys = []; + $this->values = []; + } + + /** + * @param array &$metadata + */ + private function doGet(AdapterInterface $pool, string $key, callable $callback, ?float $beta, ?array &$metadata = null): mixed + { + return null; + } +}