Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit 55ea2ae

Browse files
committedAug 25, 2024··
Bleeding edge - check type in @property tags
1 parent 030acbb commit 55ea2ae

10 files changed

+553
-1
lines changed
 

‎conf/config.level2.neon

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ rules:
4949
- PHPStan\Rules\PhpDoc\RequireExtendsDefinitionTraitRule
5050

5151
conditionalTags:
52+
PHPStan\Rules\Classes\PropertyTagRule:
53+
phpstan.rules.rule: %featureToggles.absentTypeChecks%
54+
PHPStan\Rules\Classes\PropertyTagTraitRule:
55+
phpstan.rules.rule: %featureToggles.absentTypeChecks%
5256
PHPStan\Rules\Functions\IncompatibleArrowFunctionDefaultParameterTypeRule:
5357
phpstan.rules.rule: %featureToggles.closureDefaultParameterTypeRule%
5458
PHPStan\Rules\Functions\IncompatibleClosureDefaultParameterTypeRule:
@@ -75,6 +79,12 @@ services:
7579
tags:
7680
- phpstan.rules.rule
7781

82+
-
83+
class: PHPStan\Rules\Classes\PropertyTagRule
84+
85+
-
86+
class: PHPStan\Rules\Classes\PropertyTagTraitRule
87+
7888
-
7989
class: PHPStan\Rules\PhpDoc\RequireExtendsCheck
8090
arguments:

‎conf/config.neon

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -917,6 +917,11 @@ services:
917917
checkClassCaseSensitivity: %checkClassCaseSensitivity%
918918
absentTypeChecks: %featureToggles.absentTypeChecks%
919919

920+
-
921+
class: PHPStan\Rules\Classes\PropertyTagCheck
922+
arguments:
923+
checkClassCaseSensitivity: %checkClassCaseSensitivity%
924+
920925
-
921926
class: PHPStan\Rules\Comparison\ConstantConditionRuleHelper
922927
arguments:

‎src/Reflection/ClassReflection.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1732,7 +1732,7 @@ public function getRequireImplementsTags(): array
17321732
}
17331733

17341734
/**
1735-
* @return array<PropertyTag>
1735+
* @return array<string, PropertyTag>
17361736
*/
17371737
public function getPropertyTags(): array
17381738
{
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Classes;
4+
5+
use PhpParser\Node\Stmt\ClassLike;
6+
use PHPStan\Internal\SprintfHelper;
7+
use PHPStan\Reflection\ClassReflection;
8+
use PHPStan\Reflection\ReflectionProvider;
9+
use PHPStan\Rules\ClassNameCheck;
10+
use PHPStan\Rules\ClassNameNodePair;
11+
use PHPStan\Rules\Generics\GenericObjectTypeCheck;
12+
use PHPStan\Rules\IdentifierRuleError;
13+
use PHPStan\Rules\MissingTypehintCheck;
14+
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
15+
use PHPStan\Rules\RuleErrorBuilder;
16+
use PHPStan\ShouldNotHappenException;
17+
use PHPStan\Type\Type;
18+
use PHPStan\Type\VerbosityLevel;
19+
use function array_merge;
20+
use function implode;
21+
use function sprintf;
22+
23+
final class PropertyTagCheck
24+
{
25+
26+
public function __construct(
27+
private ReflectionProvider $reflectionProvider,
28+
private ClassNameCheck $classCheck,
29+
private GenericObjectTypeCheck $genericObjectTypeCheck,
30+
private MissingTypehintCheck $missingTypehintCheck,
31+
private UnresolvableTypeHelper $unresolvableTypeHelper,
32+
private bool $checkClassCaseSensitivity,
33+
)
34+
{
35+
}
36+
37+
/**
38+
* @return list<IdentifierRuleError>
39+
*/
40+
public function check(
41+
ClassReflection $classReflection,
42+
ClassLike $node,
43+
): array
44+
{
45+
$errors = [];
46+
foreach ($classReflection->getPropertyTags() as $propertyName => $propertyTag) {
47+
$readableType = $propertyTag->getReadableType();
48+
$writableType = $propertyTag->getWritableType();
49+
50+
$types = [];
51+
$tagName = '@property';
52+
if ($readableType !== null) {
53+
if ($writableType !== null) {
54+
if ($writableType->equals($readableType)) {
55+
$types[] = $readableType;
56+
} else {
57+
$types[] = $readableType;
58+
$types[] = $writableType;
59+
}
60+
} else {
61+
$tagName = '@property-read';
62+
$types[] = $readableType;
63+
}
64+
} elseif ($writableType !== null) {
65+
$tagName = '@property-write';
66+
$types[] = $writableType;
67+
} else {
68+
throw new ShouldNotHappenException();
69+
}
70+
71+
foreach ($types as $type) {
72+
foreach ($this->checkPropertyType($classReflection, $propertyName, $tagName, $type, $node) as $error) {
73+
$errors[] = $error;
74+
}
75+
}
76+
}
77+
78+
return $errors;
79+
}
80+
81+
/**
82+
* @return list<IdentifierRuleError>
83+
*/
84+
private function checkPropertyType(ClassReflection $classReflection, string $propertyName, string $tagName, Type $type, ClassLike $node): array
85+
{
86+
if ($this->unresolvableTypeHelper->containsUnresolvableType($type)) {
87+
return [
88+
RuleErrorBuilder::message(sprintf(
89+
'PHPDoc tag %s for property %s::$%s contains unresolvable type.',
90+
$tagName,
91+
$classReflection->getDisplayName(),
92+
$propertyName,
93+
))->identifier('propertyTag.unresolvableType')
94+
->build(),
95+
];
96+
}
97+
98+
$escapedClassName = SprintfHelper::escapeFormatString($classReflection->getDisplayName());
99+
$escapedPropertyName = SprintfHelper::escapeFormatString($propertyName);
100+
$escapedTagName = SprintfHelper::escapeFormatString($tagName);
101+
102+
$errors = $this->genericObjectTypeCheck->check(
103+
$type,
104+
sprintf('PHPDoc tag %s for property %s::$%s contains generic type %%s but %%s %%s is not generic.', $escapedTagName, $escapedClassName, $escapedPropertyName),
105+
sprintf('Generic type %%s in PHPDoc tag %s for property %s::$%s does not specify all template types of %%s %%s: %%s', $escapedTagName, $escapedClassName, $escapedPropertyName),
106+
sprintf('Generic type %%s in PHPDoc tag %s for property %s::$%s specifies %%d template types, but %%s %%s supports only %%d: %%s', $escapedTagName, $escapedClassName, $escapedPropertyName),
107+
sprintf('Type %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is not subtype of template type %%s of %%s %%s.', $escapedTagName, $escapedClassName, $escapedPropertyName),
108+
sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is in conflict with %%s template type %%s of %%s %%s.', $escapedTagName, $escapedClassName, $escapedPropertyName),
109+
sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag %s for property %s::$%s is redundant, template type %%s of %%s %%s has the same variance.', $escapedTagName, $escapedClassName, $escapedPropertyName),
110+
);
111+
112+
foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($type) as [$innerName, $genericTypeNames]) {
113+
$errors[] = RuleErrorBuilder::message(sprintf(
114+
'PHPDoc tag %s for property %s::$%s contains generic %s but does not specify its types: %s',
115+
$tagName,
116+
$classReflection->getDisplayName(),
117+
$propertyName,
118+
$innerName,
119+
implode(', ', $genericTypeNames),
120+
))
121+
->identifier('missingType.generics')
122+
->build();
123+
}
124+
125+
foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) {
126+
$iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly());
127+
$errors[] = RuleErrorBuilder::message(sprintf(
128+
'%s %s has PHPDoc tag %s for property $%s with no value type specified in iterable type %s.',
129+
$classReflection->getClassTypeDescription(),
130+
$classReflection->getDisplayName(),
131+
$tagName,
132+
$propertyName,
133+
$iterableTypeDescription,
134+
))
135+
->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP)
136+
->identifier('missingType.iterableValue')
137+
->build();
138+
}
139+
140+
foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) {
141+
$errors[] = RuleErrorBuilder::message(sprintf(
142+
'%s %s has PHPDoc tag %s for property $%s with no signature specified for %s.',
143+
$classReflection->getClassTypeDescription(),
144+
$classReflection->getDisplayName(),
145+
$tagName,
146+
$propertyName,
147+
$callableType->describe(VerbosityLevel::typeOnly()),
148+
))->identifier('missingType.callable')->build();
149+
}
150+
151+
foreach ($type->getReferencedClasses() as $class) {
152+
if (!$this->reflectionProvider->hasClass($class)) {
153+
$errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag %s for property %s::$%s contains unknown class %s.', $tagName, $classReflection->getDisplayName(), $propertyName, $class))
154+
->identifier('class.notFound')
155+
->discoveringSymbolsTip()
156+
->build();
157+
} elseif ($this->reflectionProvider->getClass($class)->isTrait()) {
158+
$errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag %s for property %s::$%s contains invalid type %s.', $tagName, $classReflection->getDisplayName(), $propertyName, $class))
159+
->identifier('propertyTag.trait')
160+
->build();
161+
} else {
162+
$errors = array_merge(
163+
$errors,
164+
$this->classCheck->checkClassNames([
165+
new ClassNameNodePair($class, $node),
166+
], $this->checkClassCaseSensitivity),
167+
);
168+
}
169+
}
170+
171+
return $errors;
172+
}
173+
174+
}

‎src/Rules/Classes/PropertyTagRule.php

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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\Node\InClassNode;
8+
use PHPStan\Rules\Rule;
9+
10+
/**
11+
* @implements Rule<InClassNode>
12+
*/
13+
final class PropertyTagRule implements Rule
14+
{
15+
16+
public function __construct(private PropertyTagCheck $check)
17+
{
18+
}
19+
20+
public function getNodeType(): string
21+
{
22+
return InClassNode::class;
23+
}
24+
25+
public function processNode(Node $node, Scope $scope): array
26+
{
27+
return $this->check->check($node->getClassReflection(), $node->getOriginalNode());
28+
}
29+
30+
}
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\Classes;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Reflection\ReflectionProvider;
8+
use PHPStan\Rules\Rule;
9+
10+
/**
11+
* @implements Rule<Node\Stmt\Trait_>
12+
*/
13+
final class PropertyTagTraitRule implements Rule
14+
{
15+
16+
public function __construct(private PropertyTagCheck $check, private ReflectionProvider $reflectionProvider)
17+
{
18+
}
19+
20+
public function getNodeType(): string
21+
{
22+
return Node\Stmt\Trait_::class;
23+
}
24+
25+
public function processNode(Node $node, Scope $scope): array
26+
{
27+
$traitName = $node->namespacedName;
28+
if ($traitName === null) {
29+
return [];
30+
}
31+
32+
if (!$this->reflectionProvider->hasClass($traitName->toString())) {
33+
return [];
34+
}
35+
36+
return $this->check->check($this->reflectionProvider->getClass($traitName->toString()), $node);
37+
}
38+
39+
}
Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Classes;
4+
5+
use PHPStan\Rules\ClassCaseSensitivityCheck;
6+
use PHPStan\Rules\ClassForbiddenNameCheck;
7+
use PHPStan\Rules\ClassNameCheck;
8+
use PHPStan\Rules\Generics\GenericObjectTypeCheck;
9+
use PHPStan\Rules\MissingTypehintCheck;
10+
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
11+
use PHPStan\Rules\Rule as TRule;
12+
use PHPStan\Testing\RuleTestCase;
13+
14+
/**
15+
* @extends RuleTestCase<PropertyTagRule>
16+
*/
17+
class PropertyTagRuleTest extends RuleTestCase
18+
{
19+
20+
protected function getRule(): TRule
21+
{
22+
$reflectionProvider = $this->createReflectionProvider();
23+
24+
return new PropertyTagRule(
25+
new PropertyTagCheck(
26+
$reflectionProvider,
27+
new ClassNameCheck(
28+
new ClassCaseSensitivityCheck($reflectionProvider, true),
29+
new ClassForbiddenNameCheck(self::getContainer()),
30+
),
31+
new GenericObjectTypeCheck(),
32+
new MissingTypehintCheck(true, true, true, true, []),
33+
new UnresolvableTypeHelper(),
34+
true,
35+
),
36+
);
37+
}
38+
39+
public function testRule(): void
40+
{
41+
$tipText = 'Learn more at https://phpstan.org/user-guide/discovering-symbols';
42+
$fooClassLine = 23;
43+
44+
$this->analyse([__DIR__ . '/data/property-tag.php'], [
45+
[
46+
'PHPDoc tag @property for property PropertyTag\Foo::$a contains unknown class PropertyTag\intt.',
47+
$fooClassLine,
48+
$tipText,
49+
],
50+
[
51+
'PHPDoc tag @property for property PropertyTag\Foo::$b contains unknown class PropertyTag\intt.',
52+
$fooClassLine,
53+
$tipText,
54+
],
55+
[
56+
'PHPDoc tag @property for property PropertyTag\Foo::$c contains unknown class PropertyTag\stringg.',
57+
$fooClassLine,
58+
$tipText,
59+
],
60+
[
61+
'PHPDoc tag @property for property PropertyTag\Foo::$c contains unknown class PropertyTag\intt.',
62+
$fooClassLine,
63+
$tipText,
64+
],
65+
[
66+
'PHPDoc tag @property for property PropertyTag\Foo::$d contains unknown class PropertyTag\intt.',
67+
$fooClassLine,
68+
$tipText,
69+
],
70+
[
71+
'PHPDoc tag @property for property PropertyTag\Foo::$e contains unknown class PropertyTag\stringg.',
72+
$fooClassLine,
73+
$tipText,
74+
],
75+
[
76+
'PHPDoc tag @property for property PropertyTag\Foo::$e contains unknown class PropertyTag\intt.',
77+
$fooClassLine,
78+
$tipText,
79+
],
80+
[
81+
'PHPDoc tag @property-read for property PropertyTag\Foo::$f contains unknown class PropertyTag\intt.',
82+
$fooClassLine,
83+
$tipText,
84+
],
85+
[
86+
'PHPDoc tag @property-write for property PropertyTag\Foo::$g contains unknown class PropertyTag\stringg.',
87+
$fooClassLine,
88+
$tipText,
89+
],
90+
[
91+
'PHPDoc tag @property for property PropertyTag\Bar::$unresolvable contains unresolvable type.',
92+
31,
93+
],
94+
[
95+
'PHPDoc tag @property for property PropertyTag\TestGenerics::$a contains generic type Exception<int> but class Exception is not generic.',
96+
51,
97+
],
98+
[
99+
'Generic type PropertyTag\Generic<int> in PHPDoc tag @property for property PropertyTag\TestGenerics::$b does not specify all template types of class PropertyTag\Generic: T, U',
100+
51,
101+
],
102+
[
103+
'Generic type PropertyTag\Generic<int, string, float> in PHPDoc tag @property for property PropertyTag\TestGenerics::$c specifies 3 template types, but class PropertyTag\Generic supports only 2: T, U',
104+
51,
105+
],
106+
[
107+
'Type string in generic type PropertyTag\Generic<string, string> in PHPDoc tag @property for property PropertyTag\TestGenerics::$d is not subtype of template type T of int of class PropertyTag\Generic.',
108+
51,
109+
],
110+
[
111+
'PHPDoc tag @property for property PropertyTag\MissingGenerics::$a contains generic class PropertyTag\Generic but does not specify its types: T, U',
112+
59,
113+
],
114+
[
115+
'Class PropertyTag\MissingIterableValue has PHPDoc tag @property for property $a with no value type specified in iterable type array.',
116+
67,
117+
'See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type',
118+
],
119+
[
120+
'Class PropertyTag\MissingCallableSignature has PHPDoc tag @property for property $a with no signature specified for callable.',
121+
75,
122+
],
123+
[
124+
'PHPDoc tag @property for property PropertyTag\NonexistentClasses::$a contains unknown class PropertyTag\Nonexistent.',
125+
85,
126+
'Learn more at https://phpstan.org/user-guide/discovering-symbols',
127+
],
128+
[
129+
'PHPDoc tag @property for property PropertyTag\NonexistentClasses::$b contains invalid type PropertyTagTrait\Foo.',
130+
85,
131+
],
132+
[
133+
'Class PropertyTag\Foo referenced with incorrect case: PropertyTag\fOO.',
134+
85,
135+
],
136+
]);
137+
}
138+
139+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Rules\Classes;
4+
5+
use PHPStan\Rules\ClassCaseSensitivityCheck;
6+
use PHPStan\Rules\ClassForbiddenNameCheck;
7+
use PHPStan\Rules\ClassNameCheck;
8+
use PHPStan\Rules\Generics\GenericObjectTypeCheck;
9+
use PHPStan\Rules\MissingTypehintCheck;
10+
use PHPStan\Rules\PhpDoc\UnresolvableTypeHelper;
11+
use PHPStan\Rules\Rule as TRule;
12+
use PHPStan\Testing\RuleTestCase;
13+
14+
/**
15+
* @extends RuleTestCase<PropertyTagTraitRule>
16+
*/
17+
class PropertyTagTraitRuleTest extends RuleTestCase
18+
{
19+
20+
protected function getRule(): TRule
21+
{
22+
$reflectionProvider = $this->createReflectionProvider();
23+
24+
return new PropertyTagTraitRule(
25+
new PropertyTagCheck(
26+
$reflectionProvider,
27+
new ClassNameCheck(
28+
new ClassCaseSensitivityCheck($reflectionProvider, true),
29+
new ClassForbiddenNameCheck(self::getContainer()),
30+
),
31+
new GenericObjectTypeCheck(),
32+
new MissingTypehintCheck(true, true, true, true, []),
33+
new UnresolvableTypeHelper(),
34+
true,
35+
),
36+
$reflectionProvider,
37+
);
38+
}
39+
40+
public function testRule(): void
41+
{
42+
$this->analyse([__DIR__ . '/data/property-tag-trait.php'], [
43+
[
44+
'PHPDoc tag @property for property PropertyTagTrait\Foo::$foo contains unknown class PropertyTagTrait\intt.',
45+
8,
46+
'Learn more at https://phpstan.org/user-guide/discovering-symbols',
47+
],
48+
]);
49+
}
50+
51+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
namespace PropertyTagTrait;
4+
5+
/**
6+
* @property intt $foo
7+
*/
8+
trait Foo
9+
{
10+
11+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
namespace PropertyTag;
4+
5+
/**
6+
* @property intt $a
7+
*
8+
* @property intt $b
9+
* @property-read intt $b
10+
*
11+
* @property intt $c
12+
* @property-read stringg $c
13+
*
14+
* @property-write intt $d
15+
* @property-read intt $d
16+
*
17+
* @property-write intt $e
18+
* @property-read stringg $e
19+
*
20+
* @property-read intt $f
21+
* @property-write stringg $g
22+
*/
23+
class Foo
24+
{
25+
26+
}
27+
28+
/**
29+
* @property string&int $unresolvable
30+
*/
31+
class Bar
32+
{
33+
34+
}
35+
36+
/**
37+
* @template T of int
38+
* @template U
39+
*/
40+
class Generic
41+
{
42+
43+
}
44+
45+
/**
46+
* @property \Exception<int> $a
47+
* @property Generic<int> $b
48+
* @property Generic<int, string, float> $c
49+
* @property Generic<string, string> $d
50+
*/
51+
class TestGenerics
52+
{
53+
54+
}
55+
56+
/**
57+
* @property Generic $a
58+
*/
59+
class MissingGenerics
60+
{
61+
62+
}
63+
64+
/**
65+
* @property Generic<int, array> $a
66+
*/
67+
class MissingIterableValue
68+
{
69+
70+
}
71+
72+
/**
73+
* @property Generic<int, callable> $a
74+
*/
75+
class MissingCallableSignature
76+
{
77+
78+
}
79+
80+
/**
81+
* @property Nonexistent $a
82+
* @property \PropertyTagTrait\Foo $b
83+
* @property fOO $c
84+
*/
85+
class NonexistentClasses
86+
{
87+
88+
}
89+
90+
91+
// todo nonexistent class
92+
// todo trait
93+
// todo class name case

0 commit comments

Comments
 (0)
Please sign in to comment.