Skip to content

Commit 014db69

Browse files
Add phpstan-sealed support
1 parent 5ab9acc commit 014db69

16 files changed

+508
-1
lines changed

src/Dependency/DependencyResolver.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -536,6 +536,15 @@ private function addClassToDependencies(string $className, array &$dependenciesR
536536
}
537537
}
538538

539+
foreach ($classReflection->getSealedTags() as $sealedTag) {
540+
foreach ($sealedTag->getType()->getReferencedClasses() as $referencedClass) {
541+
if (!$this->reflectionProvider->hasClass($referencedClass)) {
542+
continue;
543+
}
544+
$dependenciesReflections[] = $this->reflectionProvider->getClass($referencedClass);
545+
}
546+
}
547+
539548
foreach ($classReflection->getTemplateTags() as $templateTag) {
540549
foreach ($templateTag->getBound()->getReferencedClasses() as $referencedClass) {
541550
if (!$this->reflectionProvider->hasClass($referencedClass)) {

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

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

0 commit comments

Comments
 (0)