diff --git a/src/Dependency/DependencyResolver.php b/src/Dependency/DependencyResolver.php index df0b940f6b1..7b365ce3311 100644 --- a/src/Dependency/DependencyResolver.php +++ b/src/Dependency/DependencyResolver.php @@ -448,6 +448,18 @@ public function resolveDependencies(Node $node, Scope $scope): NodeDependencies } } } + + if ($scope->isInClass()) { + $declaringReflection = $scope->isInTrait() ? $scope->getTraitReflection() : $scope->getClassReflection(); + $resolvedPhpDoc = $declaringReflection->getResolvedPhpDoc(); + if ($resolvedPhpDoc !== null) { + foreach ($resolvedPhpDoc->getUsesTags() as $usesTag) { + foreach ($usesTag->getType()->getReferencedClasses() as $referencedClass) { + $this->addClassToDependencies($referencedClass, $dependenciesReflections); + } + } + } + } } elseif ($node instanceof Node\Expr\Instanceof_) { if ($node->class instanceof Name) { $this->addClassToDependencies($scope->resolveName($node->class), $dependenciesReflections); diff --git a/src/Rules/Generics/ClassLevelUsedTraitsRule.php b/src/Rules/Generics/ClassLevelUsedTraitsRule.php new file mode 100644 index 00000000000..b623332fb7e --- /dev/null +++ b/src/Rules/Generics/ClassLevelUsedTraitsRule.php @@ -0,0 +1,129 @@ + + */ +#[RegisteredRule(level: 2)] +#[ValidatesStubFiles] +final class ClassLevelUsedTraitsRule implements Rule +{ + + public function __construct( + private PhpDocStringResolver $phpDocStringResolver, + private FileTypeMapper $fileTypeMapper, + private GenericAncestorsCheck $genericAncestorsCheck, + ) + { + } + + public function getNodeType(): string + { + return Node\Stmt\ClassLike::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + if (!isset($node->namespacedName)) { + // anonymous class + return []; + } + + if ($node instanceof Node\Stmt\Class_) { + $typeDescription = 'class'; + } elseif ($node instanceof Node\Stmt\Interface_) { + $typeDescription = 'interface'; + } elseif ($node instanceof Node\Stmt\Trait_) { + $typeDescription = 'trait'; + } elseif ($node instanceof Node\Stmt\Enum_) { + $typeDescription = 'enum'; + } else { + return []; + } + + // resolving the PHPDoc types of every class-like is not for free and can + // change the outcome of recursion detection in phpdoc type resolution, + // so bail out early unless the doc comment really contains @use tags + $phpDocNode = $this->phpDocStringResolver->resolve($docComment->getText()); + $hasUsesTagValues = false; + foreach (['@use', '@template-use', '@phpstan-use'] as $tagName) { + if (count($phpDocNode->getUsesTagValues($tagName)) > 0) { + $hasUsesTagValues = true; + break; + } + } + if (!$hasUsesTagValues) { + return []; + } + + $className = (string) $node->namespacedName; + $resolvedPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $className, + null, + null, + $docComment->getText(), + ); + $useTags = $resolvedPhpDoc->getUsesTags(); + if (count($useTags) === 0) { + return []; + } + + $traitNames = []; + foreach ($node->getTraitUses() as $traitUse) { + foreach ($traitUse->traits as $traitNameNode) { + $traitNames[] = $traitNameNode; + } + } + + $description = sprintf('%s %s', $typeDescription, SprintfHelper::escapeFormatString($className)); + $escapedDescription = SprintfHelper::escapeFormatString($description); + $upperCaseDescription = ucfirst($description); + $escapedUpperCaseDescription = SprintfHelper::escapeFormatString($upperCaseDescription); + + return $this->genericAncestorsCheck->check( + $traitNames, + array_map(static fn (UsesTag $tag): Type => $tag->getType(), $useTags), + sprintf('%s @use tag contains incompatible type %%s.', $escapedUpperCaseDescription), + sprintf('%s @use tag contains unresolvable type.', $upperCaseDescription), + sprintf('%s has @use tag, but does not use any trait.', $upperCaseDescription), + sprintf('The @use tag of %s describes %%s but the %s uses %%s.', $escapedDescription, $typeDescription), + 'PHPDoc tag @use contains generic type %s but %s %s is not generic.', + 'Generic type %s in PHPDoc tag @use does not specify all template types of %s %s: %s', + 'Generic type %s in PHPDoc tag @use specifies %d template types, but %s %s supports only %d: %s', + 'Type %s in generic type %s in PHPDoc tag @use is not subtype of template type %s of %s %s.', + 'Call-site variance annotation of %s in generic type %s in PHPDoc tag @use is not allowed.', + 'PHPDoc tag @use has invalid type %s.', + sprintf('%s uses generic trait %%s but does not specify its types: %%s', $escapedUpperCaseDescription), + sprintf('in used type %%s of %s', $escapedDescription), + // the missing-generics check is left entirely to UsedTraitsRule, which runs + // for every trait use statement and takes class-level @use tags into account + array_map(static fn (Node\Name $traitNameNode): string => $traitNameNode->toString(), $traitNames), + ); + } + +} diff --git a/src/Rules/Generics/GenericAncestorsCheck.php b/src/Rules/Generics/GenericAncestorsCheck.php index 47d5c574cfe..afec77beeac 100644 --- a/src/Rules/Generics/GenericAncestorsCheck.php +++ b/src/Rules/Generics/GenericAncestorsCheck.php @@ -49,6 +49,7 @@ public function __construct( /** * @param array $nameNodes * @param array $ancestorTypes + * @param list $namesWithTypesSpecifiedElsewhere names exempt from the missing-generics check because a tag for them exists in another location (e.g. a class-level `@use` tag) * @return list */ public function check( @@ -66,6 +67,7 @@ public function check( string $invalidTypeMessage, string $genericClassInNonGenericObjectType, string $invalidVarianceMessage, + array $namesWithTypesSpecifiedElsewhere = [], ): array { $names = array_fill_keys(array_map(static fn (Name $nameNode): string => $nameNode->toString(), $nameNodes), true); @@ -165,6 +167,9 @@ public function check( if ($this->checkMissingTypehints) { foreach (array_keys($unusedNames) as $unusedName) { + if (in_array($unusedName, $namesWithTypesSpecifiedElsewhere, true)) { + continue; + } if (!$this->reflectionProvider->hasClass($unusedName)) { continue; } diff --git a/src/Rules/Generics/UsedTraitsRule.php b/src/Rules/Generics/UsedTraitsRule.php index 83cc055bdb1..fc0f450b8e7 100644 --- a/src/Rules/Generics/UsedTraitsRule.php +++ b/src/Rules/Generics/UsedTraitsRule.php @@ -12,6 +12,7 @@ use PHPStan\ShouldNotHappenException; use PHPStan\Type\FileTypeMapper; use PHPStan\Type\Type; +use function array_keys; use function array_map; use function sprintf; use function strtolower; @@ -61,6 +62,13 @@ public function processNode(Node $node, Scope $scope): array $useTags = $resolvedPhpDoc->getUsesTags(); } + $classLevelUseTags = []; + $declaringReflection = $scope->isInTrait() ? $scope->getTraitReflection() : $scope->getClassReflection(); + $classLevelResolvedPhpDoc = $declaringReflection->getResolvedPhpDoc(); + if ($classLevelResolvedPhpDoc !== null) { + $classLevelUseTags = $classLevelResolvedPhpDoc->getUsesTags(); + } + $typeDescription = strtolower($scope->getClassReflection()->getClassTypeDescription()); $description = sprintf('%s %s', $typeDescription, SprintfHelper::escapeFormatString($className)); if ($traitName !== null) { @@ -87,6 +95,7 @@ public function processNode(Node $node, Scope $scope): array 'PHPDoc tag @use has invalid type %s.', sprintf('%s uses generic trait %%s but does not specify its types: %%s', $escapedUpperCaseDescription), sprintf('in used type %%s of %s', $escapedDescription), + array_keys($classLevelUseTags), ); } diff --git a/src/Type/FileTypeMapper.php b/src/Type/FileTypeMapper.php index 2381ceb8231..d1d56f68c90 100644 --- a/src/Type/FileTypeMapper.php +++ b/src/Type/FileTypeMapper.php @@ -21,6 +21,7 @@ use PHPStan\PhpDoc\PhpDocStringResolver; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDoc\Tag\TemplateTag; +use PHPStan\PhpDoc\Tag\UsesTag; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; @@ -255,19 +256,24 @@ public function getNameScope( null, $traitDocComment, )->getUsesTags(); - $useType = null; - foreach ($useTags as $useTag) { - $useTagType = $useTag->getType(); - if (!$useTagType instanceof GenericObjectType) { - continue; - } - - if ($useTagType->getClassName() !== $traitReflection->getName()) { - continue; + $useType = self::findUseTagType($useTags, $traitReflection->getName()); + if ($useType === null) { + // no statement-level @use tag matched the trait - fall back + // to @use tags in the PHPDoc of the class-like using the trait + $traitUseClassLikeName = $lookForTraitName ?? $traitClassName; + if ($reflectionProvider->hasClass($traitUseClassLikeName)) { + $traitUseClassLikeDocComment = $reflectionProvider->getClass($traitUseClassLikeName)->getNativeReflection()->getDocComment(); + if ($traitUseClassLikeDocComment !== false) { + $classLevelUseTags = $this->getResolvedPhpDoc( + $traitFileName, + $traitClassName, + $lookForTraitName, + null, + $traitUseClassLikeDocComment, + )->getUsesTags(); + $useType = self::findUseTagType($classLevelUseTags, $traitReflection->getName()); + } } - - $useType = $useTagType; - break; } $traitTemplateTypeMap = $traitReflection->getTemplateTypeMap(); $namesToUnset = []; @@ -783,6 +789,27 @@ private function chooseTemplateTagValueNodesByPriority(array $tags): array return $resolved; } + /** + * @param array $useTags + */ + private static function findUseTagType(array $useTags, string $traitName): ?GenericObjectType + { + foreach ($useTags as $useTag) { + $useTagType = $useTag->getType(); + if (!$useTagType instanceof GenericObjectType) { + continue; + } + + if ($useTagType->getClassName() !== $traitName) { + continue; + } + + return $useTagType; + } + + return null; + } + /** * @return array */ diff --git a/tests/PHPStan/Analyser/nsrt/class-level-use-tag.php b/tests/PHPStan/Analyser/nsrt/class-level-use-tag.php new file mode 100644 index 00000000000..65bbbe3760b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/class-level-use-tag.php @@ -0,0 +1,161 @@ += 8.1 + +namespace ClassLevelUseTag; + +use ClassLevelUseTag\GenericTrait as AliasedGenericTrait; +use function PHPStan\Testing\assertType; + +/** @template T */ +trait GenericTrait +{ + + /** @var T */ + private $value; + + /** @return T */ + public function get() + { + return $this->value; + } + +} + +/** @template TOther */ +trait OtherGenericTrait +{ + + /** @return TOther */ + public function getOther() + { + throw new \Exception(); + } + +} + +/** + * @use GenericTrait + */ +class ClassLevelUse +{ + + use GenericTrait; + +} + +/** + * @phpstan-use GenericTrait + */ +class PrefixedClassLevelUse +{ + + use GenericTrait; + +} + +/** + * @use AliasedGenericTrait + */ +class AliasedClassLevelUse +{ + + use AliasedGenericTrait; + +} + +class NoUseTag +{ + + use GenericTrait; + +} + +/** + * @use GenericTrait + */ +class StatementLevelWins +{ + + /** @use GenericTrait */ + use GenericTrait; + +} + +/** + * @use GenericTrait + * @use OtherGenericTrait + */ +class MultipleTraits +{ + + use GenericTrait; + use OtherGenericTrait; + +} + +/** + * @template T + * @use GenericTrait + */ +class ForwardedTemplate +{ + + use GenericTrait; + +} + +/** + * @template TNested + * @use GenericTrait + */ +trait NestedTrait +{ + + use GenericTrait; + +} + +/** + * @use NestedTrait + */ +class UsesNestedTrait +{ + + use NestedTrait; + +} + +/** + * @use OtherGenericTrait + */ +enum SomeEnum +{ + + use OtherGenericTrait; + +} + +/** + * @param ForwardedTemplate<\DateTimeImmutable> $forwarded + */ +function test( + ClassLevelUse $a, + PrefixedClassLevelUse $b, + AliasedClassLevelUse $aliased, + NoUseTag $c, + StatementLevelWins $d, + MultipleTraits $e, + ForwardedTemplate $forwarded, + UsesNestedTrait $f, + SomeEnum $g, +): void { + assertType('int', $a->get()); + assertType('string', $b->get()); + assertType('float', $aliased->get()); + assertType('mixed', $c->get()); + assertType('string', $d->get()); + assertType('int', $e->get()); + assertType('string', $e->getOther()); + assertType('DateTimeImmutable', $forwarded->get()); + assertType('bool', $f->get()); + assertType('int', $g->getOther()); +} diff --git a/tests/PHPStan/Rules/Generics/ClassLevelUsedTraitsRuleTest.php b/tests/PHPStan/Rules/Generics/ClassLevelUsedTraitsRuleTest.php new file mode 100644 index 00000000000..86d1dc44ed2 --- /dev/null +++ b/tests/PHPStan/Rules/Generics/ClassLevelUsedTraitsRuleTest.php @@ -0,0 +1,75 @@ + + */ +class ClassLevelUsedTraitsRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new ClassLevelUsedTraitsRule( + self::getContainer()->getByType(PhpDocStringResolver::class), + self::getContainer()->getByType(FileTypeMapper::class), + new GenericAncestorsCheck( + self::createReflectionProvider(), + new GenericObjectTypeCheck(), + new VarianceCheck(), + new UnresolvableTypeHelper(), + [], + true, + ), + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/class-level-used-traits.php'], [ + [ + 'PHPDoc tag @use contains generic type ClassLevelUsedTraits\NongenericTrait but trait ClassLevelUsedTraits\NongenericTrait is not generic.', + 17, + ], + [ + 'Type int in generic type ClassLevelUsedTraits\GenericTrait in PHPDoc tag @use is not subtype of template type T of object of trait ClassLevelUsedTraits\GenericTrait.', + 25, + ], + [ + 'Class ClassLevelUsedTraits\NoTraits has @use tag, but does not use any trait.', + 41, + ], + [ + 'The @use tag of class ClassLevelUsedTraits\WrongTrait describes ClassLevelUsedTraits\NongenericTrait but the class uses ClassLevelUsedTraits\GenericTrait.', + 47, + ], + [ + 'Generic type ClassLevelUsedTraits\GenericTrait in PHPDoc tag @use specifies 2 template types, but trait ClassLevelUsedTraits\GenericTrait supports only 1: T', + 55, + ], + [ + 'Call-site variance annotation of covariant Throwable in generic type ClassLevelUsedTraits\GenericTrait in PHPDoc tag @use is not allowed.', + 63, + ], + [ + 'Type mixed in generic type ClassLevelUsedTraits\GenericTrait in PHPDoc tag @use is not subtype of template type T of object of trait ClassLevelUsedTraits\GenericTrait.', + 74, + ], + [ + 'Interface ClassLevelUsedTraits\SomeInterface has @use tag, but does not use any trait.', + 90, + ], + [ + 'Type int in generic type ClassLevelUsedTraits\GenericTrait in PHPDoc tag @use is not subtype of template type T of object of trait ClassLevelUsedTraits\GenericTrait.', + 100, + ], + ]); + } + +} diff --git a/tests/PHPStan/Rules/Generics/UsedTraitsRuleTest.php b/tests/PHPStan/Rules/Generics/UsedTraitsRuleTest.php index 783d238b535..10e9751d85a 100644 --- a/tests/PHPStan/Rules/Generics/UsedTraitsRuleTest.php +++ b/tests/PHPStan/Rules/Generics/UsedTraitsRuleTest.php @@ -59,6 +59,10 @@ public function testRule(): void 'Call-site variance annotation of covariant Throwable in generic type UsedTraits\GenericTrait in PHPDoc tag @use is not allowed.', 69, ], + [ + 'Class UsedTraits\WithUnrelatedClassLevelUseTag uses generic trait UsedTraits\GenericTrait but does not specify its types: T', + 99, + ], ]); } diff --git a/tests/PHPStan/Rules/Generics/data/class-level-used-traits.php b/tests/PHPStan/Rules/Generics/data/class-level-used-traits.php new file mode 100644 index 00000000000..2c087e500b1 --- /dev/null +++ b/tests/PHPStan/Rules/Generics/data/class-level-used-traits.php @@ -0,0 +1,105 @@ += 8.1 + +namespace ClassLevelUsedTraits; + +trait NongenericTrait +{ + +} + +/** @template T of object */ +trait GenericTrait +{ + +} + +/** @use NongenericTrait<\stdClass> */ +class Foo +{ + + use NongenericTrait; + +} + +/** @use GenericTrait */ +class Bar +{ + + use GenericTrait; + +} + +/** @use GenericTrait<\stdClass> */ +class Ok +{ + + use GenericTrait; + +} + +/** @use GenericTrait<\stdClass> */ +class NoTraits +{ + +} + +/** @use NongenericTrait<\stdClass> */ +class WrongTrait +{ + + use GenericTrait; + +} + +/** @use GenericTrait<\stdClass, \Exception> */ +class TooManyTypes +{ + + use GenericTrait; + +} + +/** @use GenericTrait */ +class CallSiteVariance +{ + + use GenericTrait; + +} + +/** + * @template T + * @use GenericTrait + */ +trait NestedTrait +{ + + use GenericTrait; + +} + +/** @use GenericTrait<\stdClass> */ +enum SomeEnum +{ + + use GenericTrait; + +} + +/** @use GenericTrait<\stdClass> */ +interface SomeInterface +{ + +} + +namespace ClassLevelUsedTraitsAlias; + +use ClassLevelUsedTraits\GenericTrait as AliasedGenericTrait; + +/** @use AliasedGenericTrait */ +class InvalidBound +{ + + use AliasedGenericTrait; + +} diff --git a/tests/PHPStan/Rules/Generics/data/used-traits.php b/tests/PHPStan/Rules/Generics/data/used-traits.php index f34fb5ffb99..80d6d9993f6 100644 --- a/tests/PHPStan/Rules/Generics/data/used-traits.php +++ b/tests/PHPStan/Rules/Generics/data/used-traits.php @@ -83,3 +83,19 @@ class Sit use GenericDefault; } + +/** @use GenericTrait<\stdClass> */ +class WithClassLevelUseTag +{ + + use GenericTrait; + +} + +/** @use NongenericTrait<\stdClass> */ +class WithUnrelatedClassLevelUseTag +{ + + use GenericTrait; + +}