Skip to content

Commit 293a6ea

Browse files
ondrejmirtesclaude
andcommitted
Add unused-label rule on level 4, gated behind bleeding edge
The new UnusedLabelRule reports when a label is never targeted by any goto in the same scope. It uses the LABEL_IS_USED attribute set by GotoLabelVisitor. Enabled only with bleeding edge via the unusedLabel feature toggle. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e656df7 commit 293a6ea

10 files changed

Lines changed: 196 additions & 3 deletions

File tree

‎Makefile‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ lint:
4444
--exclude tests/PHPStan/Rules/Keywords/data/continue-break-property-hook.php \
4545
--exclude tests/PHPStan/Rules/Keywords/data/goto-undefined-label.php \
4646
--exclude tests/PHPStan/Rules/Keywords/data/goto-undefined-label-property-hook.php \
47+
--exclude tests/PHPStan/Rules/Keywords/data/unused-label-property-hook.php \
4748
--exclude tests/PHPStan/Rules/Keywords/data/bug-13790-break.php \
4849
--exclude tests/PHPStan/Rules/Keywords/data/bug-13790-continue.php \
4950
--exclude tests/PHPStan/Rules/Properties/data/invalid-callable-property-type.php \

‎conf/bleedingEdge.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@ parameters:
1919
checkDateIntervalConstructor: true
2020
reportMethodPurityOverride: true
2121
checkDynamicConstantNameValues: true
22+
unusedLabel: true

‎conf/config.level4.neon‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ conditionalTags:
1212
phpstan.rules.rule: %exceptions.check.tooWideThrowType%
1313
PHPStan\Rules\Exceptions\TooWidePropertyHookThrowTypeRule:
1414
phpstan.rules.rule: %exceptions.check.tooWideThrowType%
15+
PHPStan\Rules\Keywords\UnusedLabelRule:
16+
phpstan.rules.rule: %featureToggles.unusedLabel%
1517

1618
parameters:
1719
checkAdvancedIsset: true
@@ -30,3 +32,6 @@ services:
3032
class: PHPStan\Rules\Exceptions\TooWidePropertyHookThrowTypeRule
3133
arguments:
3234
checkProtectedAndPublicMethods: %checkTooWideThrowTypesInProtectedAndPublicMethods%
35+
36+
-
37+
class: PHPStan\Rules\Keywords\UnusedLabelRule

‎conf/config.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ parameters:
4646
checkDateIntervalConstructor: false
4747
reportMethodPurityOverride: false
4848
checkDynamicConstantNameValues: false
49+
unusedLabel: false
4950
fileExtensions:
5051
- php
5152
checkAdvancedIsset: false

‎conf/parametersSchema.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ parametersSchema:
4848
checkDateIntervalConstructor: bool()
4949
reportMethodPurityOverride: bool()
5050
checkDynamicConstantNameValues: bool()
51+
unusedLabel: bool()
5152
])
5253
fileExtensions: listOf(string())
5354
checkAdvancedIsset: bool()

‎src/Parser/GotoLabelVisitor.php‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ final class GotoLabelVisitor extends NodeVisitorAbstract
2323

2424
public const GOTO_LABEL_UNDEFINED_ATTRIBUTE = 'gotoLabelUndefined';
2525

26+
public const LABEL_IS_USED_ATTRIBUTE = 'labelIsUsed';
27+
2628
/** @var array<int, array{labels: array<string, Node\Stmt\Label>, gotos: list<Goto_>}> */
2729
private array $scopeStack = [];
2830

@@ -261,12 +263,18 @@ private function popScope(): void
261263
return;
262264
}
263265

266+
$usedLabelNames = [];
264267
foreach ($frame['gotos'] as $goto) {
265-
if (isset($frame['labels'][$goto->name->toString()])) {
266-
continue;
268+
$gotoName = $goto->name->toString();
269+
if (!isset($frame['labels'][$gotoName])) {
270+
$goto->setAttribute(self::GOTO_LABEL_UNDEFINED_ATTRIBUTE, true);
271+
} else {
272+
$usedLabelNames[$gotoName] = true;
267273
}
274+
}
268275

269-
$goto->setAttribute(self::GOTO_LABEL_UNDEFINED_ATTRIBUTE, true);
276+
foreach ($frame['labels'] as $name => $label) {
277+
$label->setAttribute(self::LABEL_IS_USED_ATTRIBUTE, isset($usedLabelNames[$name]));
270278
}
271279
}
272280

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Keywords;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Parser\GotoLabelVisitor;
8+
use PHPStan\Rules\Rule;
9+
use PHPStan\Rules\RuleErrorBuilder;
10+
use function sprintf;
11+
12+
/**
13+
* @implements Rule<Node\Stmt\Label>
14+
*/
15+
final class UnusedLabelRule implements Rule
16+
{
17+
18+
public function getNodeType(): string
19+
{
20+
return Node\Stmt\Label::class;
21+
}
22+
23+
public function processNode(Node $node, Scope $scope): array
24+
{
25+
if ($node->getAttribute(GotoLabelVisitor::LABEL_IS_USED_ATTRIBUTE) !== false) {
26+
return [];
27+
}
28+
29+
return [
30+
RuleErrorBuilder::message(sprintf(
31+
"Label '%s' is unused.",
32+
$node->name->toString(),
33+
))
34+
->identifier('label.unused')
35+
->build(),
36+
];
37+
}
38+
39+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Keywords;
4+
5+
use PHPStan\Rules\Rule;
6+
use PHPStan\Testing\RuleTestCase;
7+
use PHPUnit\Framework\Attributes\RequiresPhp;
8+
9+
/**
10+
* @extends RuleTestCase<UnusedLabelRule>
11+
*/
12+
class UnusedLabelRuleTest extends RuleTestCase
13+
{
14+
15+
protected function getRule(): Rule
16+
{
17+
return new UnusedLabelRule();
18+
}
19+
20+
public function testRule(): void
21+
{
22+
$this->analyse([__DIR__ . '/data/unused-label.php'], [
23+
[
24+
"Label 'unused' is unused.",
25+
15,
26+
],
27+
[
28+
"Label 'unused1' is unused.",
29+
31,
30+
],
31+
[
32+
"Label 'unused2' is unused.",
33+
35,
34+
],
35+
[
36+
"Label 'outside' is unused.",
37+
41,
38+
],
39+
]);
40+
}
41+
42+
#[RequiresPhp('>= 8.4.0')]
43+
public function testPropertyHook(): void
44+
{
45+
$this->analyse([__DIR__ . '/data/unused-label-property-hook.php'], [
46+
[
47+
"Label 'unused' is unused.",
48+
21,
49+
],
50+
]);
51+
}
52+
53+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php // lint >= 8.4
2+
3+
declare(strict_types = 1);
4+
5+
namespace UnusedLabelPropertyHook;
6+
7+
class Foo
8+
{
9+
10+
public int $value {
11+
get {
12+
goto end;
13+
echo "unreachable";
14+
end:
15+
return 42;
16+
}
17+
}
18+
19+
public int $broken {
20+
get {
21+
unused:
22+
return 0;
23+
}
24+
}
25+
26+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace UnusedLabel;
4+
5+
function usedLabel(): void
6+
{
7+
goto end;
8+
echo "unreachable";
9+
end:
10+
echo "done";
11+
}
12+
13+
function unusedLabel(): void
14+
{
15+
unused:
16+
echo "hello";
17+
}
18+
19+
function backwardGotoUsed(): void
20+
{
21+
retry:
22+
$result = rand(0, 1);
23+
if ($result === 0) {
24+
goto retry;
25+
}
26+
}
27+
28+
function multipleLabels(): void
29+
{
30+
goto used;
31+
unused1:
32+
echo "not used";
33+
used:
34+
echo "used";
35+
unused2:
36+
echo "not used either";
37+
}
38+
39+
function crossBoundaryNotUsed(): void
40+
{
41+
outside:
42+
$fn = function () {
43+
goto inside;
44+
inside:
45+
echo "hello";
46+
};
47+
echo "done";
48+
}
49+
50+
function labelInNestedStructure(): void
51+
{
52+
if (rand(0, 1) === 1) {
53+
goto end;
54+
}
55+
echo "hello";
56+
end:
57+
echo "done";
58+
}

0 commit comments

Comments
 (0)