Skip to content

Commit 6cf3c67

Browse files
Add phpstan-sealed support
1 parent 5ab9acc commit 6cf3c67

14 files changed

+496
-0
lines changed

src/PhpDoc/PhpDocNodeResolver.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use PHPStan\PhpDoc\Tag\RequireExtendsTag;
2020
use PHPStan\PhpDoc\Tag\RequireImplementsTag;
2121
use PHPStan\PhpDoc\Tag\ReturnTag;
22+
use PHPStan\PhpDoc\Tag\SealedTypeTag;
2223
use PHPStan\PhpDoc\Tag\SelfOutTypeTag;
2324
use PHPStan\PhpDoc\Tag\TemplateTag;
2425
use PHPStan\PhpDoc\Tag\ThrowsTag;
@@ -524,6 +525,24 @@ public function resolveRequireImplementsTags(PhpDocNode $phpDocNode, NameScope $
524525
return $resolved;
525526
}
526527

528+
/**
529+
* @return array<SealedTypeTag>
530+
*/
531+
public function resolveSealedTags(PhpDocNode $phpDocNode, NameScope $nameScope): array
532+
{
533+
$resolved = [];
534+
535+
foreach (['@psalm-inheritors', '@phpstan-sealed'] as $tagName) {
536+
foreach ($phpDocNode->getSealedTagValues($tagName) as $tagValue) {
537+
$resolved[] = new SealedTypeTag(
538+
$this->typeNodeResolver->resolve($tagValue->type, $nameScope)
539+
);
540+
}
541+
}
542+
543+
return $resolved;
544+
}
545+
527546
/**
528547
* @return array<string, TypeAliasTag>
529548
*/

src/PhpDoc/ResolvedPhpDocBlock.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
use PHPStan\PhpDoc\Tag\RequireExtendsTag;
1717
use PHPStan\PhpDoc\Tag\RequireImplementsTag;
1818
use PHPStan\PhpDoc\Tag\ReturnTag;
19+
use PHPStan\PhpDoc\Tag\SealedTypeTag;
1920
use PHPStan\PhpDoc\Tag\SelfOutTypeTag;
2021
use PHPStan\PhpDoc\Tag\TemplateTag;
2122
use PHPStan\PhpDoc\Tag\ThrowsTag;
@@ -111,6 +112,9 @@ final class ResolvedPhpDocBlock
111112
/** @var array<RequireImplementsTag>|false */
112113
private array|false $requireImplementsTags = false;
113114

115+
/** @var array<SealedTypeTag>|false */
116+
private array|false $sealedTypeTags = false;
117+
114118
/** @var array<TypeAliasTag>|false */
115119
private array|false $typeAliasTags = false;
116120

@@ -282,6 +286,7 @@ public function merge(array $parents, array $parentPhpDocBlocks): self
282286
$result->mixinTags = $this->getMixinTags();
283287
$result->requireExtendsTags = $this->getRequireExtendsTags();
284288
$result->requireImplementsTags = $this->getRequireImplementsTags();
289+
$result->sealedTypeTags = $this->getSealedTags();
285290
$result->typeAliasTags = $this->getTypeAliasTags();
286291
$result->typeAliasImportTags = $this->getTypeAliasImportTags();
287292
$result->assertTags = self::mergeAssertTags($this->getAssertTags(), $parents, $parentPhpDocBlocks);
@@ -663,6 +668,21 @@ public function getRequireImplementsTags(): array
663668
return $this->requireImplementsTags;
664669
}
665670

671+
/**
672+
* @return array<SealedTypeTag>
673+
*/
674+
public function getSealedTags(): array
675+
{
676+
if ($this->sealedTypeTags === false) {
677+
$this->sealedTypeTags = $this->phpDocNodeResolver->resolveSealedTags(
678+
$this->phpDocNode,
679+
$this->getNameScope(),
680+
);
681+
}
682+
683+
return $this->sealedTypeTags;
684+
}
685+
666686
/**
667687
* @return array<TypeAliasTag>
668688
*/

src/PhpDoc/Tag/SealedTypeTag.php

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\PhpDoc\Tag;
4+
5+
use PHPStan\Type\Type;
6+
7+
/**
8+
* @api
9+
*/
10+
final class SealedTypeTag implements TypedTag
11+
{
12+
13+
public function __construct(private Type $type)
14+
{
15+
}
16+
17+
public function getType(): Type
18+
{
19+
return $this->type;
20+
}
21+
22+
public function withType(Type $type): self
23+
{
24+
return new self($type);
25+
}
26+
27+
}

src/Reflection/ClassReflection.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
use PHPStan\PhpDoc\Tag\PropertyTag;
2424
use PHPStan\PhpDoc\Tag\RequireExtendsTag;
2525
use PHPStan\PhpDoc\Tag\RequireImplementsTag;
26+
use PHPStan\PhpDoc\Tag\SealedTypeTag;
2627
use PHPStan\PhpDoc\Tag\TemplateTag;
2728
use PHPStan\PhpDoc\Tag\TypeAliasImportTag;
2829
use PHPStan\PhpDoc\Tag\TypeAliasTag;
@@ -1887,6 +1888,19 @@ public function getRequireImplementsTags(): array
18871888
return $resolvedPhpDoc->getRequireImplementsTags();
18881889
}
18891890

1891+
/**
1892+
* @return array<SealedTypeTag>
1893+
*/
1894+
public function getSealedTags(): array
1895+
{
1896+
$resolvedPhpDoc = $this->getResolvedPhpDoc();
1897+
if ($resolvedPhpDoc === null) {
1898+
return [];
1899+
}
1900+
1901+
return $resolvedPhpDoc->getSealedTags();
1902+
}
1903+
18901904
/**
18911905
* @return array<string, PropertyTag>
18921906
*/

src/Rules/ClassNameUsageLocation.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ final class ClassNameUsageLocation
3838
public const PHPDOC_TAG_PROPERTY = 'propertyTag';
3939
public const PHPDOC_TAG_REQUIRE_EXTENDS = 'requireExtends';
4040
public const PHPDOC_TAG_REQUIRE_IMPLEMENTS = 'requireImplements';
41+
public const PHPDOC_TAG_SEALED = 'sealed';
4142
public const STATIC_METHOD_CALL = 'staticMethod';
4243
public const PHPDOC_TAG_TEMPLATE_BOUND = 'templateBound';
4344
public const PHPDOC_TAG_TEMPLATE_DEFAULT = 'templateDefault';

src/Rules/Classes/SealedRule.php

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Classes;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\DependencyInjection\RegisteredRule;
8+
use PHPStan\Node\InClassNode;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use PHPStan\Type\ObjectType;
12+
use PHPStan\Type\VerbosityLevel;
13+
use function sprintf;
14+
15+
/**
16+
* @implements Rule<InClassNode>
17+
*/
18+
#[RegisteredRule(level: 0)]
19+
final class SealedRule implements Rule
20+
{
21+
22+
public function getNodeType(): string
23+
{
24+
return InClassNode::class;
25+
}
26+
27+
public function processNode(Node $node, Scope $scope): array
28+
{
29+
$classReflection = $node->getClassReflection();
30+
if ($classReflection->isEnum()) {
31+
return [];
32+
}
33+
34+
$className = $classReflection->getName();
35+
36+
$parents = array_values($classReflection->getImmediateInterfaces());
37+
$parentClass = $classReflection->getParentClass();
38+
if ($parentClass !== null) {
39+
$parents[] = $parentClass;
40+
}
41+
42+
$errors = [];
43+
foreach ($parents as $parent) {
44+
$sealedTags = $parent->getSealedTags();
45+
foreach ($sealedTags as $sealedTag) {
46+
$type = $sealedTag->getType();
47+
if ($type->isSuperTypeOf(new ObjectType($className))->yes()) {
48+
continue;
49+
}
50+
51+
$errors[] = RuleErrorBuilder::message(
52+
sprintf(
53+
'%s %s is sealed and only permits %s as subtypes, %s given.',
54+
$parent->isInterface() ? 'Interface' : 'Class',
55+
$parent->getDisplayName(),
56+
$type->describe(VerbosityLevel::typeOnly()),
57+
$classReflection->getDisplayName(),
58+
),
59+
)
60+
->identifier('class.sealed')
61+
->build();
62+
}
63+
}
64+
65+
return $errors;
66+
}
67+
68+
}

src/Rules/PhpDoc/InvalidPHPStanDocTagRule.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ final class InvalidPHPStanDocTagRule implements Rule
5858
'@phpstan-readonly-allow-private-mutation',
5959
'@phpstan-require-extends',
6060
'@phpstan-require-implements',
61+
'@phpstan-sealed',
6162
'@phpstan-param-immediately-invoked-callable',
6263
'@phpstan-param-later-invoked-callable',
6364
'@phpstan-param-closure-this',
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\PhpDoc;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\DependencyInjection\AutowiredParameter;
8+
use PHPStan\DependencyInjection\RegisteredRule;
9+
use PHPStan\Node\InClassNode;
10+
use PHPStan\Reflection\ReflectionProvider;
11+
use PHPStan\Rules\ClassNameCheck;
12+
use PHPStan\Rules\ClassNameNodePair;
13+
use PHPStan\Rules\ClassNameUsageLocation;
14+
use PHPStan\Rules\Rule;
15+
use PHPStan\Rules\RuleErrorBuilder;
16+
use PHPStan\Type\VerbosityLevel;
17+
use function array_column;
18+
use function array_map;
19+
use function array_merge;
20+
use function count;
21+
use function sprintf;
22+
23+
/**
24+
* @implements Rule<InClassNode>
25+
*/
26+
#[RegisteredRule(level: 0)]
27+
final class SealedDefinitionClassRule implements Rule
28+
{
29+
30+
public function __construct(
31+
private ClassNameCheck $classCheck,
32+
#[AutowiredParameter]
33+
private bool $checkClassCaseSensitivity,
34+
#[AutowiredParameter(ref: '%tips.discoveringSymbols%')]
35+
private bool $discoveringSymbolsTip,
36+
)
37+
{
38+
}
39+
40+
public function getNodeType(): string
41+
{
42+
return InClassNode::class;
43+
}
44+
45+
public function processNode(Node $node, Scope $scope): array
46+
{
47+
$classReflection = $node->getClassReflection();
48+
$sealedTags = $classReflection->getSealedTags();
49+
50+
if ($classReflection->isEnum()) {
51+
return [
52+
RuleErrorBuilder::message('PHPDoc tag @phpstan-sealed is only valid on class or interface.')
53+
->identifier('sealed.onEnum')
54+
->build(),
55+
];
56+
}
57+
58+
$errors = [];
59+
foreach ($sealedTags as $sealedTag) {
60+
$type = $sealedTag->getType();
61+
$classNames = $type->getObjectClassNames();
62+
if (count($classNames) === 0) {
63+
$errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-sealed contains non-object type %s.', $type->describe(VerbosityLevel::typeOnly())))
64+
->identifier('sealed.nonObject')
65+
->build();
66+
continue;
67+
}
68+
69+
$referencedClassReflections = array_map(static fn ($reflection) => [$reflection, $reflection->getName()], $type->getObjectClassReflections());
70+
$referencedClassReflectionsMap = array_column($referencedClassReflections, 0, 1);
71+
foreach ($classNames as $class) {
72+
$referencedClassReflection = $referencedClassReflectionsMap[$class] ?? null;
73+
if ($referencedClassReflection === null) {
74+
$errorBuilder = RuleErrorBuilder::message(sprintf('PHPDoc tag @phpstan-sealed contains unknown class %s.', $class))
75+
->identifier('class.notFound');
76+
77+
if ($this->discoveringSymbolsTip) {
78+
$errorBuilder->discoveringSymbolsTip();
79+
}
80+
81+
$errors[] = $errorBuilder->build();
82+
continue;
83+
}
84+
85+
$errors = array_merge(
86+
$errors,
87+
$this->classCheck->checkClassNames($scope, [
88+
new ClassNameNodePair($class, $node),
89+
], ClassNameUsageLocation::from(ClassNameUsageLocation::PHPDOC_TAG_SEALED), $this->checkClassCaseSensitivity),
90+
);
91+
}
92+
}
93+
94+
return $errors;
95+
}
96+
97+
}
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\PhpDoc;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\DependencyInjection\RegisteredRule;
8+
use PHPStan\Reflection\ReflectionProvider;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use function count;
12+
13+
/**
14+
* @implements Rule<Node\Stmt\Trait_>
15+
*/
16+
#[RegisteredRule(level: 0)]
17+
final class SealedDefinitionTraitRule implements Rule
18+
{
19+
public function __construct(
20+
private ReflectionProvider $reflectionProvider,
21+
)
22+
{
23+
}
24+
25+
public function getNodeType(): string
26+
{
27+
return Node\Stmt\Trait_::class;
28+
}
29+
30+
public function processNode(Node $node, Scope $scope): array
31+
{
32+
if (
33+
$node->namespacedName === null
34+
|| !$this->reflectionProvider->hasClass($node->namespacedName->toString())
35+
) {
36+
return [];
37+
}
38+
39+
$traitReflection = $this->reflectionProvider->getClass($node->namespacedName->toString());
40+
$sealedTags = $traitReflection->getSealedTags();
41+
42+
if (count($sealedTags) === 0) {
43+
return [];
44+
}
45+
46+
return [
47+
RuleErrorBuilder::message('PHPDoc tag @phpstan-sealed is only valid on class or interface.')
48+
->identifier('sealed.onTrait')
49+
->build(),
50+
];
51+
}
52+
53+
}

0 commit comments

Comments
 (0)