diff --git a/extension.neon b/extension.neon index f6f372e..5c6a90d 100644 --- a/extension.neon +++ b/extension.neon @@ -55,6 +55,8 @@ services: class: PHPStan\Rules\PHPUnit\CoversHelper - class: PHPStan\Rules\PHPUnit\AnnotationHelper + - + class: PHPStan\Rules\PHPUnit\DataProviderHelper conditionalTags: PHPStan\PhpDoc\PHPUnit\MockObjectTypeNodeResolverExtension: diff --git a/rules.neon b/rules.neon index 24a28ea..195ace0 100644 --- a/rules.neon +++ b/rules.neon @@ -8,6 +8,10 @@ rules: services: - class: PHPStan\Rules\PHPUnit\ClassCoversExistsRule - class: PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule + - + class: PHPStan\Rules\PHPUnit\DataProviderDeclarationRule + arguments: + checkFunctionNameCase: %checkFunctionNameCase% - class: PHPStan\Rules\PHPUnit\NoMissingSpaceInClassAnnotationRule - class: PHPStan\Rules\PHPUnit\NoMissingSpaceInMethodAnnotationRule @@ -16,6 +20,8 @@ conditionalTags: phpstan.rules.rule: %featureToggles.bleedingEdge% PHPStan\Rules\PHPUnit\ClassMethodCoversExistsRule: phpstan.rules.rule: %featureToggles.bleedingEdge% + PHPStan\Rules\PHPUnit\DataProviderDeclarationRule: + phpstan.rules.rule: %featureToggles.bleedingEdge% PHPStan\Rules\PHPUnit\NoMissingSpaceInClassAnnotationRule: phpstan.rules.rule: %featureToggles.bleedingEdge% PHPStan\Rules\PHPUnit\NoMissingSpaceInMethodAnnotationRule: diff --git a/src/Rules/PHPUnit/DataProviderDeclarationRule.php b/src/Rules/PHPUnit/DataProviderDeclarationRule.php new file mode 100644 index 0000000..7d1afd6 --- /dev/null +++ b/src/Rules/PHPUnit/DataProviderDeclarationRule.php @@ -0,0 +1,90 @@ + + */ +class DataProviderDeclarationRule implements Rule +{ + + /** + * Data provider helper. + * + * @var DataProviderHelper + */ + private $dataProviderHelper; + + /** + * The file type mapper. + * + * @var FileTypeMapper + */ + private $fileTypeMapper; + + /** + * When set to true, it reports data provider method with incorrect name case. + * + * @var bool + */ + private $checkFunctionNameCase; + + public function __construct( + DataProviderHelper $dataProviderHelper, + FileTypeMapper $fileTypeMapper, + bool $checkFunctionNameCase + ) + { + $this->dataProviderHelper = $dataProviderHelper; + $this->fileTypeMapper = $fileTypeMapper; + $this->checkFunctionNameCase = $checkFunctionNameCase; + } + + public function getNodeType(): string + { + return Node\Stmt\ClassMethod::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $scope->getClassReflection(); + + if ($classReflection === null || !$classReflection->isSubclassOf(TestCase::class)) { + return []; + } + + $docComment = $node->getDocComment(); + if ($docComment === null) { + return []; + } + + $methodPhpDoc = $this->fileTypeMapper->getResolvedPhpDoc( + $scope->getFile(), + $classReflection->getName(), + $scope->isInTrait() ? $scope->getTraitReflection()->getName() : null, + $node->name->toString(), + $docComment->getText() + ); + + $annotations = $this->dataProviderHelper->getDataProviderAnnotations($methodPhpDoc); + + $errors = []; + + foreach ($annotations as $annotation) { + $errors = array_merge( + $errors, + $this->dataProviderHelper->processDataProvider($scope, $annotation, $this->checkFunctionNameCase) + ); + } + + return $errors; + } + +} diff --git a/src/Rules/PHPUnit/DataProviderHelper.php b/src/Rules/PHPUnit/DataProviderHelper.php new file mode 100644 index 0000000..ef3bfcd --- /dev/null +++ b/src/Rules/PHPUnit/DataProviderHelper.php @@ -0,0 +1,102 @@ + + */ + public function getDataProviderAnnotations(?ResolvedPhpDocBlock $phpDoc): array + { + if ($phpDoc === null) { + return []; + } + + $phpDocNodes = $phpDoc->getPhpDocNodes(); + + $annotations = []; + + foreach ($phpDocNodes as $docNode) { + $annotations = array_merge( + $annotations, + $docNode->getTagsByName('@dataProvider') + ); + } + + return $annotations; + } + + /** + * @return RuleError[] errors + */ + public function processDataProvider( + Scope $scope, + PhpDocTagNode $phpDocTag, + bool $checkFunctionNameCase + ): array + { + $dataProviderName = $this->getDataProviderName($phpDocTag); + if ($dataProviderName === null) { + // Missing name is already handled in NoMissingSpaceInMethodAnnotationRule + return []; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + // Should not happen + return []; + } + + try { + $dataProviderMethodReflection = $classReflection->getNativeMethod($dataProviderName); + } catch (MissingMethodFromReflectionException $missingMethodFromReflectionException) { + $error = RuleErrorBuilder::message(sprintf( + '@dataProvider %s related method not found.', + $dataProviderName + ))->build(); + + return [$error]; + } + + $errors = []; + + if ($checkFunctionNameCase && $dataProviderName !== $dataProviderMethodReflection->getName()) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@dataProvider %s related method is used with incorrect case: %s.', + $dataProviderName, + $dataProviderMethodReflection->getName() + ))->build(); + } + + if (!$dataProviderMethodReflection->isPublic()) { + $errors[] = RuleErrorBuilder::message(sprintf( + '@dataProvider %s related method must be public.', + $dataProviderName + ))->build(); + } + + return $errors; + } + + private function getDataProviderName(PhpDocTagNode $phpDocTag): ?string + { + if (preg_match('/^[^ \t]+/', (string) $phpDocTag->value, $matches) !== 1) { + return null; + } + + return $matches[0]; + } + +} diff --git a/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php new file mode 100644 index 0000000..44434e3 --- /dev/null +++ b/tests/Rules/PHPUnit/DataProviderDeclarationRuleTest.php @@ -0,0 +1,51 @@ + + */ +class DataProviderDeclarationRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new DataProviderDeclarationRule( + new DataProviderHelper(), + self::getContainer()->getByType(FileTypeMapper::class), + true + ); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/data-provider-declaration.php'], [ + [ + '@dataProvider providebaz related method is used with incorrect case: provideBaz.', + 13, + ], + [ + '@dataProvider provideQuux related method must be public.', + 13, + ], + [ + '@dataProvider provideNonExisting related method not found.', + 66, + ], + ]); + } + + /** + * @return string[] + */ + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../extension.neon', + ]; + } +} diff --git a/tests/Rules/PHPUnit/data/data-provider-declaration.php b/tests/Rules/PHPUnit/data/data-provider-declaration.php new file mode 100644 index 0000000..2690d02 --- /dev/null +++ b/tests/Rules/PHPUnit/data/data-provider-declaration.php @@ -0,0 +1,70 @@ +