diff --git a/conf/config.level2.neon b/conf/config.level2.neon index 0d9fd56ff5..72d297bfb3 100644 --- a/conf/config.level2.neon +++ b/conf/config.level2.neon @@ -49,6 +49,10 @@ rules: - PHPStan\Rules\PhpDoc\RequireExtendsDefinitionTraitRule conditionalTags: + PHPStan\Rules\Classes\MethodTagRule: + phpstan.rules.rule: %featureToggles.absentTypeChecks% + PHPStan\Rules\Classes\MethodTagTraitRule: + phpstan.rules.rule: %featureToggles.absentTypeChecks% PHPStan\Rules\Classes\PropertyTagRule: phpstan.rules.rule: %featureToggles.absentTypeChecks% PHPStan\Rules\Classes\PropertyTagTraitRule: @@ -79,6 +83,12 @@ services: tags: - phpstan.rules.rule + - + class: PHPStan\Rules\Classes\MethodTagRule + + - + class: PHPStan\Rules\Classes\MethodTagTraitRule + - class: PHPStan\Rules\Classes\PropertyTagRule diff --git a/conf/config.neon b/conf/config.neon index 732370c728..38a1518a30 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -917,6 +917,11 @@ services: checkClassCaseSensitivity: %checkClassCaseSensitivity% absentTypeChecks: %featureToggles.absentTypeChecks% + - + class: PHPStan\Rules\Classes\MethodTagCheck + arguments: + checkClassCaseSensitivity: %checkClassCaseSensitivity% + - class: PHPStan\Rules\Classes\PropertyTagCheck arguments: diff --git a/src/PhpDoc/StubValidator.php b/src/PhpDoc/StubValidator.php index 0310ae11f8..a1409ca917 100644 --- a/src/PhpDoc/StubValidator.php +++ b/src/PhpDoc/StubValidator.php @@ -26,6 +26,9 @@ use PHPStan\Rules\Classes\LocalTypeAliasesCheck; use PHPStan\Rules\Classes\LocalTypeAliasesRule; use PHPStan\Rules\Classes\LocalTypeTraitAliasesRule; +use PHPStan\Rules\Classes\MethodTagCheck; +use PHPStan\Rules\Classes\MethodTagRule; +use PHPStan\Rules\Classes\MethodTagTraitRule; use PHPStan\Rules\Classes\MixinRule; use PHPStan\Rules\Classes\PropertyTagCheck; use PHPStan\Rules\Classes\PropertyTagRule; @@ -236,6 +239,10 @@ private function getRuleRegistry(Container $container): RuleRegistry if ((bool) $container->getParameter('featureToggles')['absentTypeChecks']) { $rules[] = new MissingMethodSelfOutTypeRule($missingTypehintCheck); + $methodTagCheck = new MethodTagCheck($reflectionProvider, $classNameCheck, $genericObjectTypeCheck, $missingTypehintCheck, $unresolvableTypeHelper, true); + $rules[] = new MethodTagRule($methodTagCheck); + $rules[] = new MethodTagTraitRule($methodTagCheck, $reflectionProvider); + $propertyTagCheck = new PropertyTagCheck($reflectionProvider, $classNameCheck, $genericObjectTypeCheck, $missingTypehintCheck, $unresolvableTypeHelper, true); $rules[] = new PropertyTagRule($propertyTagCheck); $rules[] = new PropertyTagTraitRule($propertyTagCheck, $reflectionProvider); diff --git a/src/Reflection/ClassReflection.php b/src/Reflection/ClassReflection.php index 68752bfab1..cc687fdfcc 100644 --- a/src/Reflection/ClassReflection.php +++ b/src/Reflection/ClassReflection.php @@ -1745,7 +1745,7 @@ public function getPropertyTags(): array } /** - * @return array + * @return array */ public function getMethodTags(): array { diff --git a/src/Rules/Classes/MethodTagCheck.php b/src/Rules/Classes/MethodTagCheck.php new file mode 100644 index 0000000000..1c61442c9d --- /dev/null +++ b/src/Rules/Classes/MethodTagCheck.php @@ -0,0 +1,164 @@ + + */ + public function check( + ClassReflection $classReflection, + ClassLike $node, + ): array + { + $errors = []; + foreach ($classReflection->getMethodTags() as $methodName => $methodTag) { + $i = 0; + foreach ($methodTag->getParameters() as $parameterName => $parameterTag) { + $i++; + $parameterDescription = sprintf('parameter #%d $%s', $i, $parameterName); + foreach ($this->checkMethodType($classReflection, $methodName, $parameterDescription, $parameterTag->getType(), $node) as $error) { + $errors[] = $error; + } + + if ($parameterTag->getDefaultValue() === null) { + continue; + } + + foreach ($this->checkMethodType($classReflection, $methodName, sprintf('%s default value', $parameterDescription), $parameterTag->getDefaultValue(), $node) as $error) { + $errors[] = $error; + } + } + + foreach ($this->checkMethodType($classReflection, $methodName, 'return type', $methodTag->getReturnType(), $node) as $error) { + $errors[] = $error; + } + } + + return $errors; + } + + /** + * @return list + */ + private function checkMethodType(ClassReflection $classReflection, string $methodName, string $description, Type $type, ClassLike $node): array + { + if ($this->unresolvableTypeHelper->containsUnresolvableType($type)) { + return [ + RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @method for method %s::%s() %s contains unresolvable type.', + $classReflection->getDisplayName(), + $methodName, + $description, + ))->identifier('methodTag.unresolvableType') + ->build(), + ]; + } + + $escapedClassName = SprintfHelper::escapeFormatString($classReflection->getDisplayName()); + $escapedMethodName = SprintfHelper::escapeFormatString($methodName); + $escapedDescription = SprintfHelper::escapeFormatString($description); + + $errors = $this->genericObjectTypeCheck->check( + $type, + sprintf('PHPDoc tag @method for method %s::%s() %s contains generic type %%s but %%s %%s is not generic.', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Generic type %%s in PHPDoc tag @method for method %s::%s() %s does not specify all template types of %%s %%s: %%s', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Generic type %%s in PHPDoc tag @method for method %s::%s() %s specifies %%d template types, but %%s %%s supports only %%d: %%s', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Type %%s in generic type %%s in PHPDoc tag @method for method %s::%s() %s is not subtype of template type %%s of %%s %%s.', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag @method for method %s::%s() %s is in conflict with %%s template type %%s of %%s %%s.', $escapedClassName, $escapedMethodName, $escapedDescription), + sprintf('Call-site variance of %%s in generic type %%s in PHPDoc tag @method for method %s::%s() %s is redundant, template type %%s of %%s %%s has the same variance.', $escapedClassName, $escapedMethodName, $escapedDescription), + ); + + foreach ($this->missingTypehintCheck->getNonGenericObjectTypesWithGenericClass($type) as [$innerName, $genericTypeNames]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @method for method %s::%s() %s contains generic %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $methodName, + $description, + $innerName, + implode(', ', $genericTypeNames), + )) + ->identifier('missingType.generics') + ->build(); + } + + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { + $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has PHPDoc tag @method for method %s() %s with no value type specified in iterable type %s.', + $classReflection->getClassTypeDescription(), + $classReflection->getDisplayName(), + $methodName, + $description, + $iterableTypeDescription, + )) + ->tip(MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP) + ->identifier('missingType.iterableValue') + ->build(); + } + + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has PHPDoc tag @method for method %s() %s with no signature specified for %s.', + $classReflection->getClassTypeDescription(), + $classReflection->getDisplayName(), + $methodName, + $description, + $callableType->describe(VerbosityLevel::typeOnly()), + ))->identifier('missingType.callable')->build(); + } + + foreach ($type->getReferencedClasses() as $class) { + if (!$this->reflectionProvider->hasClass($class)) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @method for method %s::%s() %s contains unknown class %s.', $classReflection->getDisplayName(), $methodName, $description, $class)) + ->identifier('class.notFound') + ->discoveringSymbolsTip() + ->build(); + } elseif ($this->reflectionProvider->getClass($class)->isTrait()) { + $errors[] = RuleErrorBuilder::message(sprintf('PHPDoc tag @method for method %s::%s() %s contains invalid type %s.', $classReflection->getDisplayName(), $methodName, $description, $class)) + ->identifier('methodTag.trait') + ->build(); + } else { + $errors = array_merge( + $errors, + $this->classCheck->checkClassNames([ + new ClassNameNodePair($class, $node), + ], $this->checkClassCaseSensitivity), + ); + } + } + + return $errors; + } + +} diff --git a/src/Rules/Classes/MethodTagRule.php b/src/Rules/Classes/MethodTagRule.php new file mode 100644 index 0000000000..cdfc6759e7 --- /dev/null +++ b/src/Rules/Classes/MethodTagRule.php @@ -0,0 +1,30 @@ + + */ +final class MethodTagRule implements Rule +{ + + public function __construct(private MethodTagCheck $check) + { + } + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + return $this->check->check($node->getClassReflection(), $node->getOriginalNode()); + } + +} diff --git a/src/Rules/Classes/MethodTagTraitRule.php b/src/Rules/Classes/MethodTagTraitRule.php new file mode 100644 index 0000000000..57f84a3941 --- /dev/null +++ b/src/Rules/Classes/MethodTagTraitRule.php @@ -0,0 +1,39 @@ + + */ +final class MethodTagTraitRule implements Rule +{ + + public function __construct(private MethodTagCheck $check, private ReflectionProvider $reflectionProvider) + { + } + + public function getNodeType(): string + { + return Node\Stmt\Trait_::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $traitName = $node->namespacedName; + if ($traitName === null) { + return []; + } + + if (!$this->reflectionProvider->hasClass($traitName->toString())) { + return []; + } + + return $this->check->check($this->reflectionProvider->getClass($traitName->toString()), $node); + } + +} diff --git a/tests/PHPStan/Rules/Classes/MethodTagRuleTest.php b/tests/PHPStan/Rules/Classes/MethodTagRuleTest.php new file mode 100644 index 0000000000..9ff4c1337a --- /dev/null +++ b/tests/PHPStan/Rules/Classes/MethodTagRuleTest.php @@ -0,0 +1,106 @@ + + */ +class MethodTagRuleTest extends RuleTestCase +{ + + protected function getRule(): TRule + { + $reflectionProvider = $this->createReflectionProvider(); + + return new MethodTagRule( + new MethodTagCheck( + $reflectionProvider, + new ClassNameCheck( + new ClassCaseSensitivityCheck($reflectionProvider, true), + new ClassForbiddenNameCheck(self::getContainer()), + ), + new GenericObjectTypeCheck(), + new MissingTypehintCheck(true, true, true, true, []), + new UnresolvableTypeHelper(), + true, + ), + ); + } + + public function testRule(): void + { + $fooClassLine = 12; + $this->analyse([__DIR__ . '/data/method-tag.php'], [ + [ + 'PHPDoc tag @method for method MethodTag\Foo::doFoo() return type contains unknown class MethodTag\intt.', + $fooClassLine, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ], + [ + 'PHPDoc tag @method for method MethodTag\Foo::doBar() parameter #1 $a contains unresolvable type.', + $fooClassLine, + ], + [ + 'PHPDoc tag @method for method MethodTag\Foo::doBaz2() parameter #1 $a default value contains unresolvable type.', + 12, + ], + [ + 'Class MethodTag\Foo has PHPDoc tag @method for method doMissingIterablueValue() return type with no value type specified in iterable type array.', + 12, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'PHPDoc tag @method for method MethodTag\TestGenerics::doA() return type contains generic type Exception but class Exception is not generic.', + 39, + ], + [ + 'Generic type MethodTag\Generic in PHPDoc tag @method for method MethodTag\TestGenerics::doB() return type does not specify all template types of class MethodTag\Generic: T, U', + 39, + ], + [ + 'Generic type MethodTag\Generic in PHPDoc tag @method for method MethodTag\TestGenerics::doC() return type specifies 3 template types, but class MethodTag\Generic supports only 2: T, U', + 39, + ], + [ + 'Type string in generic type MethodTag\Generic in PHPDoc tag @method for method MethodTag\TestGenerics::doD() return type is not subtype of template type T of int of class MethodTag\Generic.', + 39, + ], + [ + 'PHPDoc tag @method for method MethodTag\MissingGenerics::doA() return type contains generic class MethodTag\Generic but does not specify its types: T, U', + 47, + ], + [ + 'Class MethodTag\MissingIterableValue has PHPDoc tag @method for method doA() return type with no value type specified in iterable type array.', + 55, + 'See: https://phpstan.org/blog/solving-phpstan-no-value-type-specified-in-iterable-type', + ], + [ + 'Class MethodTag\MissingCallableSignature has PHPDoc tag @method for method doA() return type with no signature specified for callable.', + 63, + ], + [ + 'PHPDoc tag @method for method MethodTag\NonexistentClasses::doA() return type contains unknown class MethodTag\Nonexistent.', + 73, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ], + [ + 'PHPDoc tag @method for method MethodTag\NonexistentClasses::doB() return type contains invalid type PropertyTagTrait\Foo.', + 73, + ], + [ + 'Class MethodTag\Foo referenced with incorrect case: MethodTag\fOO.', + 73, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Classes/MethodTagTraitRuleTest.php b/tests/PHPStan/Rules/Classes/MethodTagTraitRuleTest.php new file mode 100644 index 0000000000..543e0d9d70 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/MethodTagTraitRuleTest.php @@ -0,0 +1,65 @@ + + */ +class MethodTagTraitRuleTest extends RuleTestCase +{ + + protected function getRule(): TRule + { + $reflectionProvider = $this->createReflectionProvider(); + + return new MethodTagTraitRule( + new MethodTagCheck( + $reflectionProvider, + new ClassNameCheck( + new ClassCaseSensitivityCheck($reflectionProvider, true), + new ClassForbiddenNameCheck(self::getContainer()), + ), + new GenericObjectTypeCheck(), + new MissingTypehintCheck(true, true, true, true, []), + new UnresolvableTypeHelper(), + true, + ), + $reflectionProvider, + ); + } + + public function testRule(): void + { + $fooTraitLine = 12; + $this->analyse([__DIR__ . '/data/method-tag-trait.php'], [ + [ + 'PHPDoc tag @method for method MethodTagTrait\Foo::doFoo() return type contains unknown class MethodTagTrait\intt.', + $fooTraitLine, + 'Learn more at https://phpstan.org/user-guide/discovering-symbols', + ], + [ + 'PHPDoc tag @method for method MethodTagTrait\Foo::doBar() parameter #1 $a contains unresolvable type.', + $fooTraitLine, + ], + [ + 'PHPDoc tag @method for method MethodTagTrait\Foo::doBaz2() parameter #1 $a default value contains unresolvable type.', + $fooTraitLine, + ], + [ + 'Trait MethodTagTrait\Foo has PHPDoc tag @method for method doMissingIterablueValue() return type with no value type specified in iterable type array.', + $fooTraitLine, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Classes/data/method-tag-trait.php b/tests/PHPStan/Rules/Classes/data/method-tag-trait.php new file mode 100644 index 0000000000..149d43a854 --- /dev/null +++ b/tests/PHPStan/Rules/Classes/data/method-tag-trait.php @@ -0,0 +1,23 @@ + doA() + * @method Generic doB() + * @method Generic doC() + * @method Generic doD() + */ +class TestGenerics +{ + +} + +/** + * @method Generic doA() + */ +class MissingGenerics +{ + +} + +/** + * @method Generic doA() + */ +class MissingIterableValue +{ + +} + +/** + * @method Generic doA() + */ +class MissingCallableSignature +{ + +} + +/** + * @method Nonexistent doA() + * @method \PropertyTagTrait\Foo doB() + * @method fOO doC() + */ +class NonexistentClasses +{ + +} diff --git a/tests/PHPStan/Rules/Classes/data/property-tag.php b/tests/PHPStan/Rules/Classes/data/property-tag.php index 253ce69174..f6f76e5610 100644 --- a/tests/PHPStan/Rules/Classes/data/property-tag.php +++ b/tests/PHPStan/Rules/Classes/data/property-tag.php @@ -86,8 +86,3 @@ class NonexistentClasses { } - - -// todo nonexistent class -// todo trait -// todo class name case