diff --git a/.idea/icon.png b/.idea/icon.png deleted file mode 100644 index 5f346e71c16..00000000000 Binary files a/.idea/icon.png and /dev/null differ diff --git a/composer.json b/composer.json index 3e58a44cc38..54567a8539c 100644 --- a/composer.json +++ b/composer.json @@ -128,10 +128,14 @@ "patches/DependencyChecker.patch", "patches/Resolver.patch" ], - "symfony/console": [ - "patches/OutputFormatter.patch" - ] - } + "symfony/console": [ + "patches/OutputFormatter.patch" + ], + "phpstan/phpdoc-parser": [ + "patches/TypeAliasTagValueNode.patch", + "patches/PhpDocParser.patch" + ] + } }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index c57b21204c3..5304159a256 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "eef6fd5a65675d77dee63cb2a8c53a2a", + "content-hash": "f7862880740205a816b8a3e2342cd7fe", "packages": [ { "name": "clue/ndjson-react", diff --git a/patches/PhpDocParser.patch b/patches/PhpDocParser.patch new file mode 100644 index 00000000000..167968588ff --- /dev/null +++ b/patches/PhpDocParser.patch @@ -0,0 +1,39 @@ +--- src/Parser/PhpDocParser.php ++++ src/Parser/PhpDocParser.php +@@ -1067,6 +1067,21 @@ + $alias = $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + ++ $templateTypes = []; ++ if ($tokens->tryConsumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { ++ do { ++ $startLine = $tokens->currentTokenLine(); ++ $startIndex = $tokens->currentTokenIndex(); ++ $templateTypes[] = $this->enrichWithAttributes( ++ $tokens, ++ $this->typeParser->parseTemplateTagValue($tokens), ++ $startLine, ++ $startIndex, ++ ); ++ } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); ++ $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); ++ } ++ + // support phan-type/psalm-type syntax + $tokens->tryConsumeTokenType(Lexer::TOKEN_EQUAL); + +@@ -1087,12 +1102,13 @@ + } + } + +- return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type); ++ return new Ast\PhpDoc\TypeAliasTagValueNode($alias, $type, $templateTypes); + } catch (ParserException $e) { + $this->parseOptionalDescription($tokens, false); + return new Ast\PhpDoc\TypeAliasTagValueNode( + $alias, + $this->enrichWithAttributes($tokens, new Ast\Type\InvalidTypeNode($e), $startLine, $startIndex), ++ $templateTypes, + ); + } + } diff --git a/patches/TypeAliasTagValueNode.patch b/patches/TypeAliasTagValueNode.patch new file mode 100644 index 00000000000..4137b44f237 --- /dev/null +++ b/patches/TypeAliasTagValueNode.patch @@ -0,0 +1,47 @@ +--- src/Ast/PhpDoc/TypeAliasTagValueNode.php ++++ src/Ast/PhpDoc/TypeAliasTagValueNode.php +@@ -4,6 +4,7 @@ + + use PHPStan\PhpDocParser\Ast\NodeAttributes; + use PHPStan\PhpDocParser\Ast\Type\TypeNode; ++use function implode; + use function trim; + + class TypeAliasTagValueNode implements PhpDocTagValueNode +@@ -15,15 +16,25 @@ + + public TypeNode $type; + +- public function __construct(string $alias, TypeNode $type) ++ /** @var TemplateTagValueNode[] */ ++ public array $templateTypes; ++ ++ /** ++ * @param TemplateTagValueNode[] $templateTypes ++ */ ++ public function __construct(string $alias, TypeNode $type, array $templateTypes = []) + { + $this->alias = $alias; + $this->type = $type; ++ $this->templateTypes = $templateTypes; + } + + public function __toString(): string + { +- return trim("{$this->alias} {$this->type}"); ++ $templateTypes = $this->templateTypes !== [] ++ ? '<' . implode(', ', $this->templateTypes) . '>' ++ : ''; ++ return trim("{$this->alias}{$templateTypes} {$this->type}"); + } + + /** +@@ -31,7 +42,7 @@ + */ + public static function __set_state(array $properties): self + { +- $instance = new self($properties['alias'], $properties['type']); ++ $instance = new self($properties['alias'], $properties['type'], $properties['templateTypes'] ?? []); + if (isset($properties['attributes'])) { + foreach ($properties['attributes'] as $key => $value) { + $instance->setAttribute($key, $value); diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 1e88dce58aa..e9c086bdee2 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -522,7 +522,7 @@ public function resolveTypeAliasTags(PhpDocNode $phpDocNode, NameScope $nameScop foreach ($phpDocNode->getTypeAliasTagValues($tagName) as $typeAliasTagValue) { $alias = $typeAliasTagValue->alias; $typeNode = $typeAliasTagValue->type; - $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope); + $resolved[$alias] = new TypeAliasTag($alias, $typeNode, $nameScope, $typeAliasTagValue->templateTypes); } } diff --git a/src/PhpDoc/Tag/TypeAliasTag.php b/src/PhpDoc/Tag/TypeAliasTag.php index d5cd10e5d68..df8fd58aa77 100644 --- a/src/PhpDoc/Tag/TypeAliasTag.php +++ b/src/PhpDoc/Tag/TypeAliasTag.php @@ -3,6 +3,7 @@ namespace PHPStan\PhpDoc\Tag; use PHPStan\Analyser\NameScope; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; use PHPStan\Type\TypeAlias; @@ -12,10 +13,14 @@ final class TypeAliasTag { + /** + * @param TemplateTagValueNode[] $templateTagValueNodes + */ public function __construct( private string $aliasName, private TypeNode $typeNode, private NameScope $nameScope, + private array $templateTagValueNodes = [], ) { } @@ -30,6 +35,8 @@ public function getTypeAlias(): TypeAlias return new TypeAlias( $this->typeNode, $this->nameScope, + $this->templateTagValueNodes, + $this->aliasName, ); } diff --git a/src/PhpDoc/TypeNodeResolver.php b/src/PhpDoc/TypeNodeResolver.php index 8af74bf9264..c6b544a75fe 100644 --- a/src/PhpDoc/TypeNodeResolver.php +++ b/src/PhpDoc/TypeNodeResolver.php @@ -102,6 +102,7 @@ use PHPStan\Type\StringType; use PHPStan\Type\ThisType; use PHPStan\Type\Type; +use PHPStan\Type\TypeAlias; use PHPStan\Type\TypeAliasResolver; use PHPStan\Type\TypeAliasResolverProvider; use PHPStan\Type\TypeCombinator; @@ -110,6 +111,7 @@ use PHPStan\Type\ValueOfType; use PHPStan\Type\VoidType; use Traversable; +use function array_filter; use function array_key_exists; use function array_map; use function array_values; @@ -828,6 +830,29 @@ static function (string $variance): TemplateTypeVariance { return new ErrorType(); } + // Check for a generic type alias (e.g. MyList) before falling through to + // class-based generic resolution, but only when the name is not itself a resolvable + // class — in that case the class-based path must win to preserve backward compatibility + // (a type alias like BelongsTo = \Eloquent\BelongsTo should still produce + // the same GenericObjectType that class-based resolution would have given). + $resolvedGenericName = $nameScope->resolveStringName($typeNode->type->name); + $genericTypeAlias = !$this->getReflectionProvider()->hasClass($resolvedGenericName) + ? $this->findGenericTypeAlias($typeNode->type->name, $nameScope) + : null; + if ($genericTypeAlias !== null) { + $templateNodes = $genericTypeAlias->getTemplateTagValueNodes(); + $totalParams = count($templateNodes); + $requiredParams = count(array_filter($templateNodes, static fn ($tvn) => $tvn->default === null)); + $providedArgs = count($genericTypes); + + if ($providedArgs > $totalParams || $providedArgs < $requiredParams) { + return new ErrorType(); + } + + $appType = $genericTypeAlias->createApplicationType($this, $genericTypes); + return $appType->isResolvable() ? $appType->resolve() : $appType; + } + $mainType = $this->resolveIdentifierTypeNode($typeNode->type, $nameScope); $mainTypeObjectClassNames = $mainType->getObjectClassNames(); if (count($mainTypeObjectClassNames) > 1) { @@ -1360,4 +1385,39 @@ private function getTypeAliasResolver(): TypeAliasResolver return $this->typeAliasResolverProvider->getTypeAliasResolver(); } + /** + * Returns the TypeAlias for $name if it is a generic (parameterised) type alias + * visible in the current $nameScope, or null otherwise. + */ + private function findGenericTypeAlias(string $name, NameScope $nameScope): ?TypeAlias + { + if ($nameScope->shouldBypassTypeAliases()) { + return null; + } + + // Fast path: if the name isn't registered as a type alias in this scope, skip the + // more expensive ClassReflection::getTypeAliases() call. This also prevents a circular + // NameScope-building issue: getTypeAliases() can trigger FileTypeMapper::getResolvedPhpDoc() + // which calls getNameScope() — if we are already inside getNameScope() for this class, + // that throws NameScopeAlreadyBeingCreatedException, causing the class's ResolvedPhpDocBlock + // to be poisoned with an empty block and all its type aliases to be lost. + // UsefulTypeAliasResolver uses the same guard. + if (!$nameScope->hasTypeAlias($name)) { + return null; + } + + $className = $nameScope->getClassNameForTypeAlias(); + if ($className === null || !$this->getReflectionProvider()->hasClass($className)) { + return null; + } + + $typeAliases = $this->getReflectionProvider()->getClass($className)->getTypeAliases(); + if (!array_key_exists($name, $typeAliases)) { + return null; + } + + $typeAlias = $typeAliases[$name]; + return $typeAlias->isGeneric() ? $typeAlias : null; + } + } diff --git a/src/Rules/Classes/LocalTypeAliasesCheck.php b/src/Rules/Classes/LocalTypeAliasesCheck.php index a849ecac8c4..86cd06acf5b 100644 --- a/src/Rules/Classes/LocalTypeAliasesCheck.php +++ b/src/Rules/Classes/LocalTypeAliasesCheck.php @@ -221,6 +221,22 @@ public function checkInTraitDefinitionContext(ClassReflection $reflection): arra ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($resolvedType) as [$innerAliasName, $missingParams]) { + if ($innerAliasName === $aliasName) { + continue; // skip self-referential alias bodies (circular aliases are already reported separately) + } + $errors[] = RuleErrorBuilder::message(sprintf( + '%s %s has type alias %s with generic type alias %s but does not specify its types: %s', + $reflection->getClassTypeDescription(), + $reflection->getDisplayName(), + $aliasName, + $innerAliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($resolvedType) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( '%s %s has type alias %s with no signature specified for %s.', diff --git a/src/Rules/Classes/MethodTagCheck.php b/src/Rules/Classes/MethodTagCheck.php index 88e5e3a4508..b5f863c8a7b 100644 --- a/src/Rules/Classes/MethodTagCheck.php +++ b/src/Rules/Classes/MethodTagCheck.php @@ -190,6 +190,19 @@ private function checkMethodTypeInTraitDefinitionContext(ClassReflection $classR ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @method for method %s::%s() %s contains generic type alias %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $methodName, + $description, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); $errors[] = RuleErrorBuilder::message(sprintf( diff --git a/src/Rules/Classes/MixinCheck.php b/src/Rules/Classes/MixinCheck.php index ecdfff0d92b..219981c13cc 100644 --- a/src/Rules/Classes/MixinCheck.php +++ b/src/Rules/Classes/MixinCheck.php @@ -99,6 +99,16 @@ public function checkInTraitDefinitionContext(ClassReflection $classReflection): ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag @mixin contains generic type alias %s but does not specify its types: %s', + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($type) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( '%s %s has PHPDoc tag @mixin with no signature specified for %s.', diff --git a/src/Rules/Classes/PropertyTagCheck.php b/src/Rules/Classes/PropertyTagCheck.php index 6b4a42c905a..19a10ac63ac 100644 --- a/src/Rules/Classes/PropertyTagCheck.php +++ b/src/Rules/Classes/PropertyTagCheck.php @@ -171,6 +171,19 @@ private function checkPropertyTypeInTraitDefinitionContext(ClassReflection $clas ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($type) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for property %s::$%s contains generic type alias %s but does not specify its types: %s', + $tagName, + $classReflection->getDisplayName(), + $propertyName, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getIterableTypesWithMissingValueTypehint($type) as $iterableType) { $iterableTypeDescription = $iterableType->describe(VerbosityLevel::typeOnly()); $errors[] = RuleErrorBuilder::message(sprintf( diff --git a/src/Rules/Constants/MissingClassConstantTypehintRule.php b/src/Rules/Constants/MissingClassConstantTypehintRule.php index bb2d10164bc..035990ea50d 100644 --- a/src/Rules/Constants/MissingClassConstantTypehintRule.php +++ b/src/Rules/Constants/MissingClassConstantTypehintRule.php @@ -83,6 +83,18 @@ private function processSingleConstant(ClassReflection $classReflection, string ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($constantType) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Constant %s::%s with generic type alias %s does not specify its types: %s', + $constantReflection->getDeclaringClass()->getDisplayName(), + $constantName, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($constantType) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( 'Constant %s::%s type has no signature specified for %s.', diff --git a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php index 1246dc81e9c..7eb62f17195 100644 --- a/src/Rules/Functions/MissingFunctionParameterTypehintRule.php +++ b/src/Rules/Functions/MissingFunctionParameterTypehintRule.php @@ -105,6 +105,18 @@ private function checkFunctionParameter(FunctionReflection $functionReflection, ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($parameterType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() has %s with generic type alias %s but does not specify its types: %s', + $functionReflection->getName(), + $parameterMessage, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Function %s() has %s with no signature specified for %s.', diff --git a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php index 0fd30c79f99..c58fec56ac5 100644 --- a/src/Rules/Functions/MissingFunctionReturnTypehintRule.php +++ b/src/Rules/Functions/MissingFunctionReturnTypehintRule.php @@ -67,6 +67,17 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($returnType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Function %s() return type with generic type alias %s does not specify its types: %s', + $functionReflection->getName(), + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($returnType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Function %s() return type has no signature specified for %s.', diff --git a/src/Rules/Methods/MissingMethodParameterTypehintRule.php b/src/Rules/Methods/MissingMethodParameterTypehintRule.php index 38516866320..9021a158a18 100644 --- a/src/Rules/Methods/MissingMethodParameterTypehintRule.php +++ b/src/Rules/Methods/MissingMethodParameterTypehintRule.php @@ -108,6 +108,19 @@ private function checkMethodParameter(MethodReflection $methodReflection, string ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($parameterType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with generic type alias %s but does not specify its types: %s', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $parameterMessage, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Method %s::%s() has %s with no signature specified for %s.', diff --git a/src/Rules/Methods/MissingMethodReturnTypehintRule.php b/src/Rules/Methods/MissingMethodReturnTypehintRule.php index e127a143613..f37c2631606 100644 --- a/src/Rules/Methods/MissingMethodReturnTypehintRule.php +++ b/src/Rules/Methods/MissingMethodReturnTypehintRule.php @@ -79,6 +79,18 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($returnType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() return type with generic type alias %s does not specify its types: %s', + $methodReflection->getDeclaringClass()->getDisplayName(), + $methodReflection->getName(), + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($returnType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Method %s::%s() return type has no signature specified for %s.', diff --git a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php index 63966055b63..6db302e8573 100644 --- a/src/Rules/Methods/MissingMethodSelfOutTypeRule.php +++ b/src/Rules/Methods/MissingMethodSelfOutTypeRule.php @@ -72,6 +72,19 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($selfOutType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Method %s::%s() has %s with generic type alias %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $methodReflection->getName(), + $phpDocTagMessage, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($selfOutType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Method %s::%s() has %s with no signature specified for %s.', diff --git a/src/Rules/MissingTypehintCheck.php b/src/Rules/MissingTypehintCheck.php index 07f584fb8a1..bc110a31a9e 100644 --- a/src/Rules/MissingTypehintCheck.php +++ b/src/Rules/MissingTypehintCheck.php @@ -18,6 +18,7 @@ use PHPStan\Type\Generic\GenericStaticType; use PHPStan\Type\Generic\TemplateType; use PHPStan\Type\Generic\TemplateTypeHelper; +use PHPStan\Type\GenericTypeAliasType; use PHPStan\Type\IntersectionType; use PHPStan\Type\MixedType; use PHPStan\Type\ObjectType; @@ -27,6 +28,7 @@ use function array_filter; use function array_keys; use function array_merge; +use function array_unique; use function count; use function implode; use function in_array; @@ -170,6 +172,32 @@ public function getNonGenericObjectTypesWithGenericClass(Type $type): array return $objectTypes; } + /** + * @return list List of [aliasName, missingTypeParamNames] + */ + public function getRawGenericTypeAliasesUsage(Type $type): array + { + /** @var array> $found */ + $found = []; + TypeTraverser::map($type, static function (Type $type, callable $traverse) use (&$found): Type { + if ($type instanceof GenericTypeAliasType) { + $missing = $type->getMissingRequiredParamNames(); + if ($missing !== []) { + $found[$type->getAliasName()] = $missing; + } + } + + return $traverse($type); + }); + + $result = []; + foreach ($found as $aliasName => $paramNames) { + $result[] = [$aliasName, implode(', ', array_unique($paramNames))]; + } + + return $result; + } + /** * @return Type[] */ diff --git a/src/Rules/PhpDoc/AssertRuleHelper.php b/src/Rules/PhpDoc/AssertRuleHelper.php index 0dc14b638ae..015f8ce2ced 100644 --- a/src/Rules/PhpDoc/AssertRuleHelper.php +++ b/src/Rules/PhpDoc/AssertRuleHelper.php @@ -200,6 +200,18 @@ public function check( ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($assertedType) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'PHPDoc tag %s for %s contains generic type alias %s but does not specify its types: %s', + $tagName, + $assertedExprString, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($assertedType) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( 'PHPDoc tag %s for %s has no signature specified for %s.', diff --git a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php index 7d6090f70a0..26ff9dbaea2 100644 --- a/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php +++ b/src/Rules/PhpDoc/InvalidPhpDocVarTagTypeRule.php @@ -121,6 +121,17 @@ public function processNode(Node $node, Scope $scope): array ->identifier('missingType.generics') ->build(); } + + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($varTagType) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + '%s contains generic type alias %s but does not specify its types: %s', + $identifier, + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } } $escapedIdentifier = SprintfHelper::escapeFormatString($identifier); diff --git a/src/Rules/Properties/MissingPropertyTypehintRule.php b/src/Rules/Properties/MissingPropertyTypehintRule.php index 889092b699b..d6ac5c7aae9 100644 --- a/src/Rules/Properties/MissingPropertyTypehintRule.php +++ b/src/Rules/Properties/MissingPropertyTypehintRule.php @@ -77,6 +77,18 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($propertyType) as [$aliasName, $missingParams]) { + $messages[] = RuleErrorBuilder::message(sprintf( + 'Property %s::$%s with generic type alias %s does not specify its types: %s', + $propertyReflection->getDeclaringClass()->getDisplayName(), + $node->getName(), + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($propertyType) as $callableType) { $messages[] = RuleErrorBuilder::message(sprintf( 'Property %s::$%s type has no signature specified for %s.', diff --git a/src/Rules/Properties/SetPropertyHookParameterRule.php b/src/Rules/Properties/SetPropertyHookParameterRule.php index 82f89362b82..63f3c7f2ba7 100644 --- a/src/Rules/Properties/SetPropertyHookParameterRule.php +++ b/src/Rules/Properties/SetPropertyHookParameterRule.php @@ -146,6 +146,19 @@ public function processNode(Node $node, Scope $scope): array ->build(); } + foreach ($this->missingTypehintCheck->getRawGenericTypeAliasesUsage($parameterType) as [$aliasName, $missingParams]) { + $errors[] = RuleErrorBuilder::message(sprintf( + 'Set hook for property %s::$%s has parameter $%s with generic type alias %s but does not specify its types: %s', + $classReflection->getDisplayName(), + $hookReflection->getHookedPropertyName(), + $parameter->getName(), + $aliasName, + $missingParams, + )) + ->identifier('missingType.generics') + ->build(); + } + foreach ($this->missingTypehintCheck->getCallablesWithMissingSignature($parameterType) as $callableType) { $errors[] = RuleErrorBuilder::message(sprintf( 'Set hook for property %s::$%s has parameter $%s with no signature specified for %s.', diff --git a/src/Type/Generic/TemplateTypeScope.php b/src/Type/Generic/TemplateTypeScope.php index f362ecadd4c..64c91dea0f8 100644 --- a/src/Type/Generic/TemplateTypeScope.php +++ b/src/Type/Generic/TemplateTypeScope.php @@ -3,6 +3,9 @@ namespace PHPStan\Type\Generic; use function sprintf; +use function str_starts_with; +use function strlen; +use function substr; final class TemplateTypeScope { @@ -12,6 +15,11 @@ public static function createWithAnonymousFunction(): self return new self(null, null); } + public static function createWithTypeAlias(string $className, string $aliasName): self + { + return new self($className, '__typeAlias_' . $aliasName); + } + public static function createWithFunction(string $functionName): self { return new self(null, $functionName); @@ -43,6 +51,22 @@ public function getFunctionName(): ?string return $this->functionName; } + /** @api */ + public function isTypeAlias(): bool + { + return $this->functionName !== null && str_starts_with($this->functionName, '__typeAlias_'); + } + + /** @api */ + public function getTypeAliasName(): ?string + { + if (!$this->isTypeAlias() || $this->functionName === null) { + return null; + } + + return substr($this->functionName, strlen('__typeAlias_')); + } + /** @api */ public function equals(self $other): bool { diff --git a/src/Type/GenericTypeAliasType.php b/src/Type/GenericTypeAliasType.php new file mode 100644 index 00000000000..f1913d4b3ef --- /dev/null +++ b/src/Type/GenericTypeAliasType.php @@ -0,0 +1,231 @@ +} where {@code @phpstan-type Filter} is + * declared expands lazily to the alias body with TItem substituted. + * + * Mirrors the role of GenericObjectType for classes: GenericObjectType is a class constructor + * applied to type args; GenericTypeAliasType is a type alias applied to type args. + * + * Implements LateResolvableType so TypeUtils::resolveLateResolvableTypes() expands it at the + * right moment without leaking TemplateType placeholders to the rest of the type system. + */ +final class GenericTypeAliasType implements CompoundType, LateResolvableType +{ + + use LateResolvableTypeTrait; + use NonGeneralizableTypeTrait; + + /** + * @param list $paramNames Ordered parameter names from the alias declaration. + * @param list $args Supplied type arguments (may be shorter than paramNames + * when trailing params are covered by defaults). + * @param list $defaults Per-param declared default type; null when the param has no default. + * @param list $boundFallbacks Per-param bound type used when both arg and default are absent. + */ + public function __construct( + private readonly string $aliasName, + private readonly Type $resolvedBody, + private readonly array $paramNames, + private readonly array $args, + private readonly array $defaults, + private readonly array $boundFallbacks, + ) + { + } + + public function getAliasName(): string + { + return $this->aliasName; + } + + /** + * Returns the names of required params (no declared default) that were not supplied as args. + * A non-empty list means this is a "raw" usage of a generic alias that should be reported. + * + * @return list + */ + public function getMissingRequiredParamNames(): array + { + $missing = []; + foreach ($this->paramNames as $i => $name) { + if (isset($this->args[$i]) || $this->defaults[$i] !== null) { + continue; + } + + $missing[] = $name; + } + + return $missing; + } + + public function getReferencedClasses(): array + { + $classes = $this->resolvedBody->getReferencedClasses(); + foreach ($this->args as $arg) { + $classes = array_merge($classes, $arg->getReferencedClasses()); + } + + return $classes; + } + + public function getReferencedTemplateTypes(TemplateTypeVariance $positionVariance): array + { + $refs = []; + foreach ($this->args as $arg) { + $refs = array_merge($refs, $arg->getReferencedTemplateTypes($positionVariance)); + } + + return $refs; + } + + public function equals(Type $type): bool + { + if (!$type instanceof self) { + return false; + } + + if ($this->aliasName !== $type->aliasName || count($this->args) !== count($type->args)) { + return false; + } + + foreach ($this->args as $i => $arg) { + if (!$arg->equals($type->args[$i])) { + return false; + } + } + + return true; + } + + public function describe(VerbosityLevel $level): string + { + if ($this->args === []) { + return $this->aliasName; + } + + return sprintf( + '%s<%s>', + $this->aliasName, + implode(', ', array_map(static fn (Type $t) => $t->describe($level), $this->args)), + ); + } + + public function isResolvable(): bool + { + foreach ($this->args as $arg) { + if (TypeUtils::containsTemplateType($arg)) { + return false; + } + } + + foreach (array_keys($this->paramNames) as $i) { + if (!isset($this->args[$i]) && $this->defaults[$i] === null) { + return false; + } + } + + return true; + } + + protected function getResult(): Type + { + $map = []; + foreach ($this->paramNames as $i => $name) { + $map[$name] = $this->args[$i] ?? $this->defaults[$i] ?? $this->boundFallbacks[$i]; + } + + return TemplateTypeHelper::resolveTemplateTypes( + $this->resolvedBody, + new TemplateTypeMap($map), + TemplateTypeVarianceMap::createEmpty(), + TemplateTypeVariance::createInvariant(), + ); + } + + /** + * @param callable(Type): Type $cb + */ + public function traverse(callable $cb): Type + { + $newArgs = array_map($cb, $this->args); + + foreach ($this->args as $i => $arg) { + if ($arg !== $newArgs[$i]) { + return new self( + $this->aliasName, + $this->resolvedBody, + $this->paramNames, + $newArgs, + $this->defaults, + $this->boundFallbacks, + ); + } + } + + return $this; + } + + public function traverseSimultaneously(Type $right, callable $cb): Type + { + if (!$right instanceof self) { + return $this; + } + + $newArgs = []; + $changed = false; + foreach ($this->args as $i => $arg) { + $newArg = isset($right->args[$i]) ? $cb($arg, $right->args[$i]) : $arg; + if ($newArg !== $arg) { + $changed = true; + } + + $newArgs[] = $newArg; + } + + if (!$changed) { + return $this; + } + + return new self( + $this->aliasName, + $this->resolvedBody, + $this->paramNames, + $newArgs, + $this->defaults, + $this->boundFallbacks, + ); + } + + public function toPhpDocNode(): TypeNode + { + if ($this->args === []) { + return new IdentifierTypeNode($this->aliasName); + } + + return new GenericTypeNode( + new IdentifierTypeNode($this->aliasName), + array_map(static fn (Type $t) => $t->toPhpDocNode(), $this->args), + ); + } + +} diff --git a/src/Type/TypeAlias.php b/src/Type/TypeAlias.php index 17bd6373e57..8a0f5d8ede8 100644 --- a/src/Type/TypeAlias.php +++ b/src/Type/TypeAlias.php @@ -3,18 +3,32 @@ namespace PHPStan\Type; use PHPStan\Analyser\NameScope; +use PHPStan\PhpDoc\Tag\TemplateTag; use PHPStan\PhpDoc\TypeNodeResolver; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Ast\Type\TypeNode; +use PHPStan\Type\Generic\TemplateTypeFactory; +use PHPStan\Type\Generic\TemplateTypeMap; +use PHPStan\Type\Generic\TemplateTypeScope; +use PHPStan\Type\Generic\TemplateTypeVariance; +use function array_map; +use function array_values; +use function count; final class TypeAlias { private ?Type $resolvedType = null; + /** + * @param TemplateTagValueNode[] $templateTagValueNodes + */ public function __construct( private TypeNode $typeNode, private NameScope $nameScope, + private array $templateTagValueNodes = [], + private string $aliasName = '', ) { } @@ -26,12 +40,103 @@ public static function invalid(): self return $self; } + /** + * Returns the type with TemplateType placeholders for any declared template params. + * For non-generic aliases this is the fully-resolved concrete type. + */ public function resolve(TypeNodeResolver $typeNodeResolver): Type { - return $this->resolvedType ??= $typeNodeResolver->resolve( - $this->typeNode, - $this->nameScope, + if ($this->resolvedType !== null) { + return $this->resolvedType; + } + + $nameScope = $this->nameScope; + + if (count($this->templateTagValueNodes) > 0) { + $nameScope = $this->buildNameScopeWithTemplates($typeNodeResolver, $nameScope); + } + + return $this->resolvedType = $typeNodeResolver->resolve($this->typeNode, $nameScope); + } + + /** Whether this alias was declared with type parameters (e.g. @phpstan-type Foo). */ + public function isGeneric(): bool + { + return count($this->templateTagValueNodes) > 0; + } + + /** + * @return TemplateTagValueNode[] + */ + public function getTemplateTagValueNodes(): array + { + return $this->templateTagValueNodes; + } + + /** + * Creates a GenericTypeAliasType for this alias with the given type arguments. + * + * @param list $args Concrete or partially-resolved type arguments in parameter order. + */ + public function createApplicationType(TypeNodeResolver $typeNodeResolver, array $args): GenericTypeAliasType + { + $resolvedBody = $this->resolve($typeNodeResolver); + + $paramNames = []; + $defaults = []; + $boundFallbacks = []; + + foreach (array_values($this->templateTagValueNodes) as $tvn) { + $paramNames[] = $tvn->name; + $defaults[] = $tvn->default !== null + ? $typeNodeResolver->resolve($tvn->default, $this->nameScope) + : null; + $boundFallbacks[] = $tvn->bound !== null + ? $typeNodeResolver->resolve($tvn->bound, $this->nameScope) + : new MixedType(true); + } + + return new GenericTypeAliasType( + $this->aliasName, + $resolvedBody, + $paramNames, + $args, + $defaults, + $boundFallbacks, ); } + /** + * Builds a NameScope augmented with TemplateType placeholders for each declared template param, + * so the alias body can reference them (e.g. `TFilter` resolves to a TemplateType). + */ + private function buildNameScopeWithTemplates(TypeNodeResolver $typeNodeResolver, NameScope $nameScope): NameScope + { + $templateTags = []; + foreach ($this->templateTagValueNodes as $templateTagValueNode) { + $templateTags[$templateTagValueNode->name] = new TemplateTag( + $templateTagValueNode->name, + $templateTagValueNode->bound !== null + ? $typeNodeResolver->resolve($templateTagValueNode->bound, $nameScope) + : new MixedType(true), + $templateTagValueNode->default !== null + ? $typeNodeResolver->resolve($templateTagValueNode->default, $nameScope) + : null, + TemplateTypeVariance::createInvariant(), + ); + } + + $className = $nameScope->getClassNameForTypeAlias(); + $templateTypeScope = $className !== null && $this->aliasName !== '' + ? TemplateTypeScope::createWithTypeAlias($className, $this->aliasName) + : TemplateTypeScope::createWithAnonymousFunction(); + + $templateTypeMap = new TemplateTypeMap(array_map( + static fn (TemplateTag $tag): Type => TemplateTypeFactory::fromTemplateTag($templateTypeScope, $tag), + $templateTags, + )); + + return $nameScope->withTemplateTypeMap($templateTypeMap, $templateTags); + } + } diff --git a/src/Type/UsefulTypeAliasResolver.php b/src/Type/UsefulTypeAliasResolver.php index 6577289ff93..c860cdb38d2 100644 --- a/src/Type/UsefulTypeAliasResolver.php +++ b/src/Type/UsefulTypeAliasResolver.php @@ -114,7 +114,18 @@ private function resolveLocalTypeAlias(string $aliasName, NameScope $nameScope): try { $unresolvedAlias = $localTypeAliases[$aliasName]; - $resolvedAliasType = $unresolvedAlias->resolve($this->typeNodeResolver); + + // For a generic alias used bare (no type args provided), build a GenericTypeAliasType + // with empty args. If all params have declared defaults, isResolvable() will be true and + // the type is immediately expanded to the concrete default form. When at least one param + // has no default, the GenericTypeAliasType stays unresolved so that + // MissingTypehintCheck::getRawGenericTypeAliasesUsage() can detect the bare-usage error. + if ($unresolvedAlias->isGeneric()) { + $appType = $unresolvedAlias->createApplicationType($this->typeNodeResolver, []); + $resolvedAliasType = $appType->isResolvable() ? $appType->resolve() : $appType; + } else { + $resolvedAliasType = $unresolvedAlias->resolve($this->typeNodeResolver); + } } catch (CircularTypeAliasDefinitionException) { $resolvedAliasType = new CircularTypeAliasErrorType(); } diff --git a/test-generic-aliases.php b/test-generic-aliases.php new file mode 100644 index 00000000000..6cd90a0efd9 --- /dev/null +++ b/test-generic-aliases.php @@ -0,0 +1,230 @@ + + * @phpstan-type ProviderRequest array{ + * filters?: TFilter, + * limit?: int, + * offset?: int, + * } + */ +abstract class Provider +{ + /** + * @param ProviderRequest $request + * @return array + */ + public function find(array $request): array { + return []; + } +} + +/** + * @phpstan-type AppraisalFilter array{skuId?: int, condition?: string} + * @extends Provider + */ +final class SkuProvider extends Provider +{ + #[\Override] + public function find(array $request): array + { + // PHPStan now knows $request is array{filters?: array{skuId?: int, condition?: string}, ...} + $filters = $request['filters'] ?? []; + + // This is int|null, not mixed! + $skuId = $filters['skuId'] ?? null; + + return [$skuId]; + } +} + +// --------------------------------------------------------------------------- +// Two-param alias +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Pair array{first: TFirst, second: TSecond} + */ +final class PairHolder +{ + /** + * @param Pair $pair + */ + public function use(array $pair): void + { + echo $pair['first']; // string + echo (string) $pair['second']; // int → cast to string for echo + } +} + +// --------------------------------------------------------------------------- +// With default +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Response> array{data: TData, status: int} + */ +final class ApiClient +{ + /** + * @return Response + */ + public function getUser(): array + { + return ['data' => ['id' => 1, 'name' => 'Alice'], 'status' => 200]; + } +} + +// --------------------------------------------------------------------------- +// @return of generic alias +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Page array{items: list, total: int, page: int} + */ +final class PagedRepo +{ + /** + * @return Page<\stdClass> // resolves to array{items: list, total: int, page: int} + */ + public function getPage(): array + { + return ['items' => [], 'total' => 0, 'page' => 1]; + } +} + +// --------------------------------------------------------------------------- +// @var property annotation +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Config array{key: string, value: TValue} + */ +final class Settings +{ + /** @var Config */ + public array $timeout = ['key' => 'timeout', 'value' => 30]; + + /** @var Config */ + public array $name = ['key' => 'name', 'value' => 'default']; + + public function check(): void + { + // $this->timeout['value'] — int + // $this->name['value'] — string + } +} + +// --------------------------------------------------------------------------- +// Nested generic alias (alias referencing another generic alias with type args) +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Item array{id: int, data: T} + * @phpstan-type ItemList list> + */ +final class ItemRepo +{ + /** + * @param ItemList $items // list + */ + public function process(array $items): void + { + // $items[0]['data'] — string + } +} + +// --------------------------------------------------------------------------- +// @phpstan-import-type of a generic alias, then used with type args +// --------------------------------------------------------------------------- + +/** + * @phpstan-import-type Pair from PairHolder + */ +final class PairConsumer +{ + /** + * @param Pair $p + */ + public function check(array $p): void + { + // $p['first'] — int + // $p['second'] — bool + } +} + +// --------------------------------------------------------------------------- +// Default type arg — using alias WITHOUT args should be OK (default kicks in) +// --------------------------------------------------------------------------- + +/** + * @phpstan-type WithDefault array{value: T} + */ +final class DefaultConsumer +{ + /** + * @param WithDefault $explicit no error: type arg provided + * @param WithDefault $implicit no error: T has a default (string) + */ + public function check(array $explicit, array $implicit): void + { + // $explicit['value'] — int + // $implicit['value'] — string (default applied ✓) + } +} + +// --------------------------------------------------------------------------- +// Generic alias in a standalone function (not a class method) +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Range array{min: T, max: T} + */ +final class RangeHolder +{ + /** + * @param Range $r + * @return Range + */ + public function convert(array $r): array + { + // $r['min'] — int + return ['min' => (float) $r['min'], 'max' => (float) $r['max']]; + } +} + +// --------------------------------------------------------------------------- +// Too many type args — should error +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Single array{value: T} + */ +final class TooManyArgs +{ + /** + * @param Single $x ERROR: Single takes 1 type arg, 2 given + * @phpstan-ignore parameter.unresolvableType, missingType.iterableValue + */ + public function check(array $x): void {} +} + +// --------------------------------------------------------------------------- +// Too few required type args (partial application of multi-param alias) — should error +// --------------------------------------------------------------------------- + +/** + * @phpstan-type KeyValue array{key: TKey, value: TValue} + */ +final class TooFewArgs +{ + /** + * @param KeyValue $x ERROR: KeyValue requires 2 type args, 1 given + * @phpstan-ignore parameter.unresolvableType, missingType.iterableValue + */ + public function check(array $x): void {} +} diff --git a/test-type-error-demo.php b/test-type-error-demo.php new file mode 100644 index 00000000000..af48b18090a --- /dev/null +++ b/test-type-error-demo.php @@ -0,0 +1,26 @@ +> array{filters?: TFilter, limit?: int} + */ +final class ProviderTypeError +{ + /** + * @param Request $req + */ + public function find(array $req): void + { + $filters = $req['filters'] ?? []; + + // PHPStan now knows $filters is array{skuId?: int, condition?: string} + // so this arithmetic on int + string IS caught: + $bad = ($filters['skuId'] ?? 0) + 'hello'; // Error: binary + with string + + // And this wrong-type pass is also caught: + $this->takeString($filters['skuId'] ?? 0); // Error: passing int where string expected + } + + public function takeString(string $s): void {} +} + diff --git a/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php b/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php new file mode 100644 index 00000000000..fe9bc243470 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/generic-type-aliases.php @@ -0,0 +1,235 @@ + = array> array{ + * filters?: TFilter, + * limit?: int, + * offset?: int, + * } + */ +abstract class Provider +{ + /** + * @param Request $request + */ + abstract public function find(array $request): void; +} + +class ConcreteProvider extends Provider +{ + public function find(array $request): void + { + // Access an optional key – PHPStan represents the array{filters?:Filter,...} type + // as a union of the possible ConstantArrayType shapes (with/without the optional key). + // The important thing is that Filter IS substituted: `filters` carries array{skuId?: int, condition?: string}. + assertType('array{filters?: array{skuId?: int, condition?: string}, limit?: int, offset?: int}', $request); + } +} + +// ------------------------------------------------------- +// Direct usage in the same class – simpler and more reliable test +// ------------------------------------------------------- + +/** + * @phpstan-type AppraisalFilter array{skuId?: int, condition?: string} + * + * @phpstan-type ProviderRequest> array{ + * filters?: TFilter, + * limit?: int, + * offset?: int, + * } + */ +class DirectUsage +{ + /** + * @param ProviderRequest $request + */ + public function find(array $request): void + { + assertType('array{filters?: array{skuId?: int, condition?: string}, limit?: int, offset?: int}', $request); + } +} + +// ------------------------------------------------------- +// Two template params +// ------------------------------------------------------- + +/** + * @phpstan-type Pair array{first: TFirst, second: TSecond} + */ +class PairHolder +{ + /** + * @param Pair $pair + */ + public function check(array $pair): void + { + assertType('string', $pair['first']); + assertType('int', $pair['second']); + } +} + +// ------------------------------------------------------- +// @return of generic alias with bound constraint +// ------------------------------------------------------- + +/** + * @phpstan-type Range array{min: T, max: T} + */ +class RangeHolder +{ + /** + * @param Range $r + * @return Range + */ + public function convert(array $r): array + { + assertType('int', $r['min']); + assertType('int', $r['max']); + $result = ['min' => (float) $r['min'], 'max' => (float) $r['max']]; + assertType('array{min: float, max: float}', $result); + return $result; + } +} + +// ------------------------------------------------------- +// @var property annotation +// ------------------------------------------------------- + +/** + * @phpstan-type Config array{key: string, value: TValue} + */ +class Settings +{ + /** @var Config */ + public array $timeout = ['key' => 'timeout', 'value' => 30]; + + /** @var Config */ + public array $name = ['key' => 'name', 'value' => 'default']; + + public function check(): void + { + assertType('int', $this->timeout['value']); + assertType('string', $this->name['value']); + } +} + +// ------------------------------------------------------- +// Test with list +// ------------------------------------------------------- + +/** + * @phpstan-type Paged array{items: list, total: int} + */ +class Repo +{ + /** + * @param Paged<\stdClass> $result + */ + public function check(array $result): void + { + assertType('list', $result['items']); + assertType('int', $result['total']); + } +} + +// ------------------------------------------------------- +// Nested generic alias (alias referencing another generic alias) +// ------------------------------------------------------- + +/** + * @phpstan-type Item array{id: int, data: T} + * @phpstan-type ItemList list> + */ +class ItemRepo +{ + /** + * @param ItemList $items + */ + public function process(array $items): void + { + assertType('list', $items); + } +} + +// ------------------------------------------------------- +// Test with two template params +// ------------------------------------------------------- + +/** + * @phpstan-type Map array + */ +class MapHolder +{ + /** + * @param Map $m + */ + public function check(array $m): void + { + assertType('array', $m); + } +} + +// ------------------------------------------------------- +// Default param: explicit arg vs bare usage (default applied) +// ------------------------------------------------------- + +/** + * @phpstan-type WithDefault array{value: T} + */ +class DefaultHolder +{ + /** + * @param WithDefault $explicit explicit arg overrides default + * @param WithDefault $implicit bare usage – T defaults to string + */ + public function check(array $explicit, array $implicit): void + { + assertType('int', $explicit['value']); + assertType('string', $implicit['value']); + } +} + +// ------------------------------------------------------- +// @phpstan-import-type of a generic alias +// ------------------------------------------------------- + +/** + * @phpstan-import-type Map from MapHolder + * @phpstan-import-type Paged from Repo + * @phpstan-import-type Pair from PairHolder + */ +class ImportConsumer +{ + /** + * @param Map $m + */ + public function mapCheck(array $m): void + { + assertType('array', $m); + } + + /** + * @param Paged<\DateTime> $p + */ + public function pagedCheck(array $p): void + { + assertType('list', $p['items']); + assertType('int', $p['total']); + } + + /** + * @param Pair $p + */ + public function pairCheck(array $p): void + { + assertType('int', $p['first']); + assertType('bool', $p['second']); + } +} diff --git a/tests/PHPStan/Rules/Classes/data/local-type-aliases.php b/tests/PHPStan/Rules/Classes/data/local-type-aliases.php index 152e77d8d7b..40096ec6208 100644 --- a/tests/PHPStan/Rules/Classes/data/local-type-aliases.php +++ b/tests/PHPStan/Rules/Classes/data/local-type-aliases.php @@ -104,3 +104,14 @@ class GenericsCheck { } + +/** + * Generic type alias – template params should not trigger "invalid type" errors. + * + * @phpstan-type GenericShape array{item: TItem, extra: TExtra} + */ +class GenericAlias +{ + +} + diff --git a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php index db4b818f924..9d0bbb46a6c 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodParameterTypehintRuleTest.php @@ -148,4 +148,38 @@ public function testBug7662(): void ]); } + public function testGenericTypeAliasMissingTypehint(): void + { + $this->analyse([__DIR__ . '/data/generic-type-alias-missing-typehint.php'], [ + [ + 'Method GenericTypeAliasMissingTypehint\RawUsage::check() has parameter $b with generic type alias Filter but does not specify its types: TItem', + 18, + ], + [ + 'Method GenericTypeAliasMissingTypehint\PartialDefault::check() has parameter $noArgs with generic type alias Pair but does not specify its types: TFirst', + 61, + ], + [ + 'Method GenericTypeAliasMissingTypehint\ImportedRawUsage::check() has parameter $bad with generic type alias Filter but does not specify its types: TItem', + 77, + ], + ]); + } + + public function testGenericTypeAliasWrongArgCount(): void + { + $this->analyse([__DIR__ . '/../PhpDoc/data/generic-type-alias-wrong-arg-count.php'], [ + [ + 'Method GenericTypeAliasWrongArgCount\TooManyArgs::badParam() has parameter $x with no value type specified in iterable type array.', + 17, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method GenericTypeAliasWrongArgCount\TooFewArgs::badParam() has parameter $x with no value type specified in iterable type array.', + 47, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php b/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php index 9b4d374395d..b81a6698372 100644 --- a/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php +++ b/tests/PHPStan/Rules/Methods/MissingMethodReturnTypehintRuleTest.php @@ -128,4 +128,30 @@ public function testInheritPhpDocReturnTypeWithNarrowerNativeReturnType(): void $this->analyse([__DIR__ . '/data/inherit-phpdoc-return-type-with-narrower-native-return-type.php'], []); } + public function testGenericTypeAliasMissingTypehint(): void + { + $this->analyse([__DIR__ . '/data/generic-type-alias-missing-typehint.php'], [ + [ + 'Method GenericTypeAliasMissingTypehint\RawUsage::getRaw() return type with generic type alias Filter does not specify its types: TItem', + 28, + ], + ]); + } + + public function testGenericTypeAliasWrongArgCount(): void + { + $this->analyse([__DIR__ . '/../PhpDoc/data/generic-type-alias-wrong-arg-count.php'], [ + [ + 'Method GenericTypeAliasWrongArgCount\TooManyArgs::badReturn() return type has no value type specified in iterable type array.', + 22, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + [ + 'Method GenericTypeAliasWrongArgCount\TooFewArgs::badReturn() return type has no value type specified in iterable type array.', + 52, + MissingTypehintCheck::MISSING_ITERABLE_VALUE_TYPE_TIP, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php b/tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php index 55979eeb77d..b0e0acf9996 100644 --- a/tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php +++ b/tests/PHPStan/Rules/Methods/OverridingMethodRuleTest.php @@ -56,7 +56,7 @@ public static function dataOverridingFinalMethod(): array } #[DataProvider('dataOverridingFinalMethod')] - public function testOverridingFinalMethod(int $phpVersion, string $contravariantMessage): void + public function testOverridingFinalMethod(int $phpVersion, string $contravariantMessage, string $covariantMessage): void { $errors = [ [ @@ -322,7 +322,7 @@ public function testVariadicParameterIsAlwaysOptional(): void } #[DataProvider('dataOverridingFinalMethod')] - public function testBug3403(int $phpVersion): void + public function testBug3403(int $phpVersion, string $contravariantMessage, string $covariantMessage): void { $this->phpVersionId = $phpVersion; $this->analyse([__DIR__ . '/data/bug-3403.php'], []); diff --git a/tests/PHPStan/Rules/Methods/data/generic-type-alias-missing-typehint.php b/tests/PHPStan/Rules/Methods/data/generic-type-alias-missing-typehint.php new file mode 100644 index 00000000000..e7dff97252a --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/generic-type-alias-missing-typehint.php @@ -0,0 +1,79 @@ + array{items: list} + */ +class RawUsage +{ + /** + * @param Filter $a OK: type arg provided + * @param Filter $b ERROR: Filter requires 1 type arg + */ + public function check(array $a, array $b): void {} + + /** + * @return Filter OK + */ + public function getFiltered(): array { return ['items' => []]; } + + /** + * @return Filter ERROR: Filter requires 1 type arg + */ + public function getRaw(): array { return ['items' => []]; } +} + +// --------------------------------------------------------------------------- +// Alias with a default — bare usage should NOT error +// --------------------------------------------------------------------------- + +/** + * @phpstan-type WithDefault array{value: T} + */ +class DefaultedUsage +{ + /** + * @param WithDefault $implicit OK: T has a default + * @param WithDefault $explicit OK: T provided + */ + public function check(array $implicit, array $explicit): void {} +} + +// --------------------------------------------------------------------------- +// Two-param alias, one required, one defaulted +// --------------------------------------------------------------------------- + +/** + * @phpstan-type Pair array{first: TFirst, second: TSecond} + */ +class PartialDefault +{ + /** + * @param Pair $oneArg OK: TFirst provided, TSecond defaults to bool + * @param Pair $twoArgs OK: both provided + * @param Pair $noArgs ERROR: TFirst has no default + */ + public function check(array $oneArg, array $twoArgs, array $noArgs): void {} +} + +// --------------------------------------------------------------------------- +// Imported generic alias — raw usage should also error +// --------------------------------------------------------------------------- + +/** + * @phpstan-import-type Filter from RawUsage + */ +class ImportedRawUsage +{ + /** + * @param Filter $ok OK + * @param Filter $bad ERROR: Filter requires 1 type arg + */ + public function check(array $ok, array $bad): void {} +} + diff --git a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php index 247dcce92b8..7f07d3b93d1 100644 --- a/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php +++ b/tests/PHPStan/Rules/PhpDoc/IncompatiblePhpDocTypeRuleTest.php @@ -497,4 +497,26 @@ public function testBug11463b(): void $this->analyse([__DIR__ . '/data/bug-11463b.php'], []); } + public function testGenericTypeAliasWrongArgCount(): void + { + $this->analyse([__DIR__ . '/data/generic-type-alias-wrong-arg-count.php'], [ + [ + 'PHPDoc tag @param for parameter $x contains unresolvable type.', + 17, + ], + [ + 'PHPDoc tag @return contains unresolvable type.', + 22, + ], + [ + 'PHPDoc tag @param for parameter $x contains unresolvable type.', + 47, + ], + [ + 'PHPDoc tag @return contains unresolvable type.', + 52, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/PhpDoc/data/generic-type-alias-wrong-arg-count.php b/tests/PHPStan/Rules/PhpDoc/data/generic-type-alias-wrong-arg-count.php new file mode 100644 index 00000000000..443c9d495dc --- /dev/null +++ b/tests/PHPStan/Rules/PhpDoc/data/generic-type-alias-wrong-arg-count.php @@ -0,0 +1,66 @@ + array{value: T} + */ +final class TooManyArgs +{ + /** + * @param Single $x + */ + public function badParam(array $x): void {} + + /** + * @return Single + */ + public function badReturn(): array { return ['value' => 1]; } + + /** + * @param Single $ok + */ + public function goodParam(array $ok): void {} + + /** + * @return Single + */ + public function goodReturn(): array { return ['value' => 1]; } +} + +// --------------------------------------------------------------------------- +// Too few required type args (partial application of multi-param alias) +// --------------------------------------------------------------------------- + +/** + * @phpstan-type KeyVal array{key: TKey, value: TValue} + */ +final class TooFewArgs +{ + /** + * @param KeyVal $x + */ + public function badParam(array $x): void {} + + /** + * @return KeyVal + */ + public function badReturn(): array { return ['key' => 'k', 'value' => 'v']; } + + /** + * @param KeyVal $ok + */ + public function goodParam(array $ok): void {} + + /** + * @return KeyVal + */ + public function goodReturn(): array { return ['key' => 'k', 'value' => 1]; } +} + + +