diff --git a/config/set/type-declaration-docblocks.php b/config/set/type-declaration-docblocks.php index 79b2806614e..90b75fcc9ae 100644 --- a/config/set/type-declaration-docblocks.php +++ b/config/set/type-declaration-docblocks.php @@ -6,6 +6,7 @@ use Rector\TypeDeclaration\Rector\ClassMethod\AddReturnArrayDocblockBasedOnArrayMapRector; use Rector\TypeDeclaration\Rector\ClassMethod\AddReturnDocblockForScalarArrayFromAssignsRector; use Rector\TypeDeclarationDocblocks\Rector\Class_\DocblockVarFromParamDocblockInConstructorRector; +use Rector\TypeDeclarationDocblocks\Rector\ClassMethod\AddParamArrayDocblockFromDataProviderRector; use Rector\TypeDeclarationDocblocks\Rector\ClassMethod\AddParamArrayDocblockFromDimFetchAccessRector; use Rector\TypeDeclarationDocblocks\Rector\ClassMethod\AddReturnDocblockForCommonObjectDenominatorRector; use Rector\TypeDeclarationDocblocks\Rector\ClassMethod\DocblockGetterReturnArrayFromPropertyDocblockVarRector; @@ -22,5 +23,6 @@ DocblockGetterReturnArrayFromPropertyDocblockVarRector::class, AddReturnDocblockForCommonObjectDenominatorRector::class, AddParamArrayDocblockFromDimFetchAccessRector::class, + AddParamArrayDocblockFromDataProviderRector::class, ]); }; diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/AddParamArrayDocblockFromDataProviderRectorTest.php b/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/AddParamArrayDocblockFromDataProviderRectorTest.php new file mode 100644 index 00000000000..b9bcc91f16f --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/AddParamArrayDocblockFromDataProviderRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/Fixture/some_test_with_data_provider.php.inc b/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/Fixture/some_test_with_data_provider.php.inc new file mode 100644 index 00000000000..2a295c77cab --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/Fixture/some_test_with_data_provider.php.inc @@ -0,0 +1,46 @@ + +----- + diff --git a/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/config/configured_rule.php b/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/config/configured_rule.php new file mode 100644 index 00000000000..1bfdc172f37 --- /dev/null +++ b/rules-tests/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector/config/configured_rule.php @@ -0,0 +1,9 @@ +withRules([AddParamArrayDocblockFromDataProviderRector::class]); diff --git a/rules/TypeDeclaration/Rector/ClassMethod/AddParamTypeBasedOnPHPUnitDataProviderRector.php b/rules/TypeDeclaration/Rector/ClassMethod/AddParamTypeBasedOnPHPUnitDataProviderRector.php index 91ca5eb6bc7..95d8d2534cf 100644 --- a/rules/TypeDeclaration/Rector/ClassMethod/AddParamTypeBasedOnPHPUnitDataProviderRector.php +++ b/rules/TypeDeclaration/Rector/ClassMethod/AddParamTypeBasedOnPHPUnitDataProviderRector.php @@ -4,32 +4,17 @@ namespace Rector\TypeDeclaration\Rector\ClassMethod; -use Nette\Utils\Strings; use PhpParser\Node; -use PhpParser\Node\ArrayItem; -use PhpParser\Node\Attribute; -use PhpParser\Node\AttributeGroup; -use PhpParser\Node\Expr\Array_; -use PhpParser\Node\Expr\Yield_; -use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassMethod; -use PhpParser\Node\Stmt\Return_; -use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode; -use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; -use PHPStan\Type\Constant\ConstantArrayType; use PHPStan\Type\MixedType; -use PHPStan\Type\Type; -use PHPStan\Type\TypeCombinator; -use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfo; -use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfoFactory; -use Rector\NodeTypeResolver\PHPStan\Type\TypeFactory; -use Rector\PhpParser\Node\BetterNodeFinder; use Rector\PHPStanStaticTypeMapper\Enum\TypeKind; use Rector\PHPUnit\NodeAnalyzer\TestsNodeAnalyzer; use Rector\Rector\AbstractRector; use Rector\StaticTypeMapper\StaticTypeMapper; +use Rector\TypeDeclaration\TypeAnalyzer\ParameterTypeFromDataProviderResolver; use Rector\TypeDeclaration\ValueObject\DataProviderNodes; +use Rector\TypeDeclarationDocblocks\NodeFinder\DataProviderMethodsFinder; use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; @@ -43,17 +28,10 @@ final class AddParamTypeBasedOnPHPUnitDataProviderRector extends AbstractRector */ private const ERROR_MESSAGE = 'Adds param type declaration based on PHPUnit provider return type declaration'; - /** - * @see https://regex101.com/r/hW09Vt/1 - * @var string - */ - private const METHOD_NAME_REGEX = '#^(?\w+)(\(\))?#'; - public function __construct( - private readonly TypeFactory $typeFactory, private readonly TestsNodeAnalyzer $testsNodeAnalyzer, - private readonly PhpDocInfoFactory $phpDocInfoFactory, - private readonly BetterNodeFinder $betterNodeFinder, + private readonly DataProviderMethodsFinder $dataProviderMethodsFinder, + private readonly ParameterTypeFromDataProviderResolver $parameterTypeFromDataProviderResolver, private readonly StaticTypeMapper $staticTypeMapper, ) { } @@ -131,12 +109,12 @@ public function refactor(Node $node): ?Node continue; } - $dataProviderNodes = $this->resolveDataProviderNodes($classMethod); - if ($dataProviderNodes->isEmpty()) { + $dataProviderNodes = $this->dataProviderMethodsFinder->findDataProviderNodes($node, $classMethod); + if ($dataProviderNodes->getClassMethods() === []) { continue; } - $hasClassMethodChanged = $this->refactorClassMethod($classMethod, $node, $dataProviderNodes->nodes); + $hasClassMethodChanged = $this->refactorClassMethod($classMethod, $dataProviderNodes); if ($hasClassMethodChanged) { $hasChanged = true; } @@ -149,187 +127,7 @@ public function refactor(Node $node): ?Node return null; } - private function inferParam( - Class_ $class, - int $parameterPosition, - PhpDocTagNode | Attribute $dataProviderNode - ): Type { - $dataProviderClassMethod = $this->resolveDataProviderClassMethod($class, $dataProviderNode); - if (! $dataProviderClassMethod instanceof ClassMethod) { - return new MixedType(); - } - - $returns = $this->betterNodeFinder->findReturnsScoped($dataProviderClassMethod); - if ($returns !== []) { - return $this->resolveReturnStaticArrayTypeByParameterPosition($returns, $parameterPosition); - } - - /** @var Yield_[] $yields */ - $yields = $this->betterNodeFinder->findInstancesOfInFunctionLikeScoped($dataProviderClassMethod, Yield_::class); - return $this->resolveYieldStaticArrayTypeByParameterPosition($yields, $parameterPosition); - } - - private function resolveDataProviderClassMethod( - Class_ $class, - Attribute | PhpDocTagNode $dataProviderNode - ): ?ClassMethod { - if ($dataProviderNode instanceof Attribute) { - $value = $dataProviderNode->args[0]->value; - - if (! $value instanceof String_) { - return null; - } - - $content = $value->value; - } elseif ($dataProviderNode->value instanceof GenericTagValueNode) { - $content = $dataProviderNode->value->value; - } else { - return null; - } - - $match = Strings::match($content, self::METHOD_NAME_REGEX); - if ($match === null) { - return null; - } - - $methodName = $match['method_name']; - return $class->getMethod($methodName); - } - - /** - * @param Return_[] $returns - */ - private function resolveReturnStaticArrayTypeByParameterPosition(array $returns, int $parameterPosition): Type - { - $firstReturnedExpr = $returns[0]->expr; - - if (! $firstReturnedExpr instanceof Array_) { - return new MixedType(); - } - - $paramOnPositionTypes = $this->resolveParamOnPositionTypes($firstReturnedExpr, $parameterPosition); - if ($paramOnPositionTypes === []) { - return new MixedType(); - } - - return $this->typeFactory->createMixedPassedOrUnionType($paramOnPositionTypes); - } - - /** - * @param Yield_[] $yields - */ - private function resolveYieldStaticArrayTypeByParameterPosition(array $yields, int $parameterPosition): Type - { - $paramOnPositionTypes = []; - - foreach ($yields as $yield) { - if (! $yield->value instanceof Array_) { - continue; - } - - $type = $this->getTypeFromClassMethodYield($yield->value); - - if (! $type instanceof ConstantArrayType) { - return $type; - } - - foreach ($type->getValueTypes() as $position => $valueType) { - if ($position !== $parameterPosition) { - continue; - } - - $paramOnPositionTypes[] = $valueType; - } - } - - if ($paramOnPositionTypes === []) { - return new MixedType(); - } - - return $this->typeFactory->createMixedPassedOrUnionType($paramOnPositionTypes); - } - - private function getTypeFromClassMethodYield(Array_ $classMethodYieldArray): MixedType | ConstantArrayType - { - $arrayType = $this->nodeTypeResolver->getType($classMethodYieldArray); - - // impossible to resolve - if (! $arrayType instanceof ConstantArrayType) { - return new MixedType(); - } - - return $arrayType; - } - - /** - * @return Type[] - */ - private function resolveParamOnPositionTypes(Array_ $array, int $parameterPosition): array - { - $paramOnPositionTypes = []; - - foreach ($array->items as $singleDataProvidedSet) { - if (! $singleDataProvidedSet instanceof ArrayItem || ! $singleDataProvidedSet->value instanceof Array_) { - return []; - } - - foreach ($singleDataProvidedSet->value->items as $position => $singleDataProvidedSetItem) { - if ($position !== $parameterPosition) { - continue; - } - - if (! $singleDataProvidedSetItem instanceof ArrayItem) { - continue; - } - - $paramOnPositionTypes[] = $this->nodeTypeResolver->getType($singleDataProvidedSetItem->value); - } - } - - return $paramOnPositionTypes; - } - - private function resolveDataProviderNodes(ClassMethod $classMethod): DataProviderNodes - { - $attributes = $this->getPhpDataProviderAttributes($classMethod); - - $classMethodPhpDocInfo = $this->phpDocInfoFactory->createFromNode($classMethod); - - $phpdocNodes = $classMethodPhpDocInfo instanceof PhpDocInfo ? - $classMethodPhpDocInfo->getTagsByName('@dataProvider') : []; - - return new DataProviderNodes([...$attributes, ...$phpdocNodes]); - } - - /** - * @return array - */ - private function getPhpDataProviderAttributes(ClassMethod $classMethod): array - { - $attributeName = 'PHPUnit\Framework\Attributes\DataProvider'; - - /** @var AttributeGroup[] $attrGroups */ - $attrGroups = $classMethod->attrGroups; - - $dataProviders = []; - - foreach ($attrGroups as $attrGroup) { - foreach ($attrGroup->attrs as $attribute) { - if (! $this->isName($attribute->name, $attributeName)) { - continue; - } - - $dataProviders[] = $attribute; - } - } - - return $dataProviders; - } - - /** - * @param array $dataProviderNodes - */ - private function refactorClassMethod(ClassMethod $classMethod, Class_ $class, array $dataProviderNodes): bool + private function refactorClassMethod(ClassMethod $classMethod, DataProviderNodes $dataProviderNodes): bool { $hasChanged = false; @@ -342,20 +140,18 @@ private function refactorClassMethod(ClassMethod $classMethod, Class_ $class, ar continue; } - $paramTypes = []; - foreach ($dataProviderNodes as $dataProviderNode) { - $paramTypes[] = $this->inferParam($class, $parameterPosition, $dataProviderNode); - } - - $paramTypeDeclaration = TypeCombinator::union(...$paramTypes); + $paramTypeDeclaration = $this->parameterTypeFromDataProviderResolver->resolve( + $parameterPosition, + $dataProviderNodes->getClassMethods() + ); if ($paramTypeDeclaration instanceof MixedType) { continue; } - $type = $this->staticTypeMapper->mapPHPStanTypeToPhpParserNode($paramTypeDeclaration, TypeKind::PARAM); - if ($type instanceof Node) { - $param->type = $type; + $typeNode = $this->staticTypeMapper->mapPHPStanTypeToPhpParserNode($paramTypeDeclaration, TypeKind::PARAM); + if ($typeNode instanceof Node) { + $param->type = $typeNode; $hasChanged = true; } } diff --git a/rules/TypeDeclaration/TypeAnalyzer/ParameterTypeFromDataProviderResolver.php b/rules/TypeDeclaration/TypeAnalyzer/ParameterTypeFromDataProviderResolver.php new file mode 100644 index 00000000000..f24d7e72517 --- /dev/null +++ b/rules/TypeDeclaration/TypeAnalyzer/ParameterTypeFromDataProviderResolver.php @@ -0,0 +1,151 @@ +resolveParameterTypeFromDataProvider($parameterPosition, $dataProviderClassMethod); + } + + return TypeCombinator::union(...$paramTypes); + } + + private function resolveParameterTypeFromDataProvider( + int $parameterPosition, + ClassMethod $dataProviderClassMethod + ): Type { + $returns = $this->betterNodeFinder->findReturnsScoped($dataProviderClassMethod); + if ($returns !== []) { + return $this->resolveReturnStaticArrayTypeByParameterPosition($returns, $parameterPosition); + } + + /** @var Yield_[] $yields */ + $yields = $this->betterNodeFinder->findInstancesOfInFunctionLikeScoped($dataProviderClassMethod, Yield_::class); + return $this->resolveYieldStaticArrayTypeByParameterPosition($yields, $parameterPosition); + } + + /** + * @param Return_[] $returns + */ + private function resolveReturnStaticArrayTypeByParameterPosition(array $returns, int $parameterPosition): Type + { + $firstReturnedExpr = $returns[0]->expr; + + if (! $firstReturnedExpr instanceof Array_) { + return new MixedType(); + } + + $paramOnPositionTypes = $this->resolveParamOnPositionTypes($firstReturnedExpr, $parameterPosition); + if ($paramOnPositionTypes === []) { + return new MixedType(); + } + + return $this->typeFactory->createMixedPassedOrUnionType($paramOnPositionTypes); + } + + /** + * @param Yield_[] $yields + */ + private function resolveYieldStaticArrayTypeByParameterPosition(array $yields, int $parameterPosition): Type + { + $paramOnPositionTypes = []; + + foreach ($yields as $yield) { + if (! $yield->value instanceof Array_) { + continue; + } + + $type = $this->getTypeFromClassMethodYield($yield->value); + + if (! $type instanceof ConstantArrayType) { + return $type; + } + + foreach ($type->getValueTypes() as $position => $valueType) { + if ($position !== $parameterPosition) { + continue; + } + + $paramOnPositionTypes[] = $valueType; + } + } + + if ($paramOnPositionTypes === []) { + return new MixedType(); + } + + return $this->typeFactory->createMixedPassedOrUnionType($paramOnPositionTypes); + } + + private function getTypeFromClassMethodYield(Array_ $classMethodYieldArray): MixedType | ConstantArrayType + { + $arrayType = $this->nodeTypeResolver->getType($classMethodYieldArray); + + // impossible to resolve + if (! $arrayType instanceof ConstantArrayType) { + return new MixedType(); + } + + return $arrayType; + } + + /** + * @return Type[] + */ + private function resolveParamOnPositionTypes(Array_ $array, int $parameterPosition): array + { + $paramOnPositionTypes = []; + + foreach ($array->items as $singleDataProvidedSet) { + if (! $singleDataProvidedSet instanceof ArrayItem || ! $singleDataProvidedSet->value instanceof Array_) { + return []; + } + + foreach ($singleDataProvidedSet->value->items as $position => $singleDataProvidedSetItem) { + if ($position !== $parameterPosition) { + continue; + } + + if (! $singleDataProvidedSetItem instanceof ArrayItem) { + continue; + } + + $paramOnPositionTypes[] = $this->nodeTypeResolver->getType($singleDataProvidedSetItem->value); + } + } + + return $paramOnPositionTypes; + } +} diff --git a/rules/TypeDeclaration/ValueObject/DataProviderNodes.php b/rules/TypeDeclaration/ValueObject/DataProviderNodes.php index 89d52da3690..e6bba080c7a 100644 --- a/rules/TypeDeclaration/ValueObject/DataProviderNodes.php +++ b/rules/TypeDeclaration/ValueObject/DataProviderNodes.php @@ -4,21 +4,93 @@ namespace Rector\TypeDeclaration\ValueObject; +use Nette\Utils\Strings; use PhpParser\Node\Attribute; +use PhpParser\Node\Scalar\String_; +use PhpParser\Node\Stmt\Class_; +use PhpParser\Node\Stmt\ClassMethod; +use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use Webmozart\Assert\Assert; final readonly class DataProviderNodes { /** - * @param array $nodes + * @see https://regex101.com/r/hW09Vt/1 + * @var string + */ + private const METHOD_NAME_REGEX = '#^(?\w+)(\(\))?#'; + + /** + * @param Attribute[] $attributes + * @param PhpDocTagNode[] $phpDocTagNodes */ public function __construct( - public array $nodes, + private Class_ $class, + private array $attributes, + private array $phpDocTagNodes, ) { + Assert::allIsInstanceOf($attributes, Attribute::class); + Assert::allIsInstanceOf($phpDocTagNodes, PhpDocTagNode::class); } public function isEmpty(): bool { - return $this->nodes === []; + return $this->getClassMethods() === []; + } + + /** + * @return ClassMethod[] + */ + public function getClassMethods(): array + { + $classMethods = []; + + foreach ($this->phpDocTagNodes as $phpDocTagNode) { + if ($phpDocTagNode->value instanceof GenericTagValueNode) { + $methodName = $this->matchMethodName($phpDocTagNode->value->value); + if (! is_string($methodName)) { + continue; + } + + $classMethod = $this->class->getMethod($methodName); + if (! $classMethod instanceof ClassMethod) { + continue; + } + + $classMethods[] = $classMethod; + } + } + + foreach ($this->attributes as $attribute) { + $value = $attribute->args[0]->value; + if (! $value instanceof String_) { + continue; + } + + $methodName = $this->matchMethodName($value->value); + if (! is_string($methodName)) { + continue; + } + + $classMethod = $this->class->getMethod($methodName); + if (! $classMethod instanceof ClassMethod) { + continue; + } + + $classMethods[] = $classMethod; + } + + return $classMethods; + } + + private function matchMethodName(string $content): ?string + { + $match = Strings::match($content, self::METHOD_NAME_REGEX); + if ($match === null) { + return null; + } + + return $match['method_name']; } } diff --git a/rules/TypeDeclarationDocblocks/Enum/TestClassName.php b/rules/TypeDeclarationDocblocks/Enum/TestClassName.php new file mode 100644 index 00000000000..46d52440678 --- /dev/null +++ b/rules/TypeDeclarationDocblocks/Enum/TestClassName.php @@ -0,0 +1,13 @@ +phpDocInfoFactory->createFromNode($classMethod); + if ($phpDocInfo instanceof PhpDocInfo) { + $phpdocNodes = $phpDocInfo->getTagsByName('@dataProvider'); + } else { + $phpdocNodes = []; + } + + $attributes = $this->findDataProviderAttributes($classMethod); + + return new DataProviderNodes($class, $attributes, $phpdocNodes); + } + + /** + * @return array + */ + private function findDataProviderAttributes(ClassMethod $classMethod): array + { + $dataProviders = []; + + foreach ($classMethod->attrGroups as $attrGroup) { + foreach ($attrGroup->attrs as $attribute) { + if (! $this->nodeNameResolver->isName($attribute->name, TestClassName::DATA_PROVIDER)) { + continue; + } + + $dataProviders[] = $attribute; + } + } + + return $dataProviders; + } +} diff --git a/rules/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector.php b/rules/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector.php new file mode 100644 index 00000000000..38b5fb1c078 --- /dev/null +++ b/rules/TypeDeclarationDocblocks/Rector/ClassMethod/AddParamArrayDocblockFromDataProviderRector.php @@ -0,0 +1,168 @@ +> + */ + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): ?Node + { + if (! $this->testsNodeAnalyzer->isInTestClass($node)) { + return null; + } + + $hasChanged = false; + + foreach ($node->getMethods() as $classMethod) { + if ($classMethod->getParams() === []) { + continue; + } + + if (! $this->testsNodeAnalyzer->isTestClassMethod($classMethod)) { + continue; + } + + $phpDocInfo = $this->phpDocInfoFactory->createFromNodeOrEmpty($classMethod); + + $dataProviderNodes = $this->dataProviderMethodsFinder->findDataProviderNodes($node, $classMethod); + if ($dataProviderNodes->getClassMethods() === []) { + continue; + } + + foreach ($classMethod->getParams() as $paramPosition => $param) { + // we are interested only in array params + if (! $param->type instanceof Node) { + continue; + } + + if (! $this->isName($param->type, 'array')) { + continue; + } + + /** @var string $paramName */ + $paramName = $this->getName($param->var); + + $paramTagValueNode = $phpDocInfo->getParamTagValueByName($paramName); + + // already defined, lets skip it + if ($paramTagValueNode instanceof ParamTagValueNode) { + continue; + } + + $parameterType = $this->parameterTypeFromDataProviderResolver->resolve( + $paramPosition, + $dataProviderNodes->getClassMethods() + ); + + $generalizedParameterType = $this->typeNormalizer->generalizeConstantBoolTypes($parameterType); + + $parameterTypeNode = $this->staticTypeMapper->mapPHPStanTypeToPHPStanPhpDocTypeNode( + $generalizedParameterType + ); + + $paramTagValueNode = new ParamTagValueNode($parameterTypeNode, false, '$' . $paramName, '', false); + $phpDocInfo->addTagValueNode($paramTagValueNode); + $hasChanged = true; + + $this->docBlockUpdater->updateRefactoredNodeWithPhpDocInfo($classMethod); + } + + } + + if (! $hasChanged) { + return null; + } + + return $node; + } +}