diff --git a/README.rst b/README.rst index 2bfb067a874..292b61fe3d2 100644 --- a/README.rst +++ b/README.rst @@ -1247,6 +1247,16 @@ Choose from the list of available rules: - ``types`` (a subset of ``['normal', 'final', 'abstract']``): what types of classes to mark as internal; defaults to ``['normal', 'final']`` +* **php_unit_method_casing** + + Enforce camel (or snake) case for PHPUnit test methods, following + configuration. + + Configuration options: + + - ``case`` (``'camel_case'``, ``'snake_case'``): apply camel or snake case to test + methods; defaults to ``'camel_case'`` + * **php_unit_mock** [@PHPUnit54Migration:risky, @PHPUnit55Migration:risky, @PHPUnit56Migration:risky, @PHPUnit57Migration:risky, @PHPUnit60Migration:risky] Usages of ``->getMock`` and @@ -1319,7 +1329,8 @@ Choose from the list of available rules: Configuration options: - ``case`` (``'camel'``, ``'snake'``): whether to camel or snake case when adding the - test prefix; defaults to ``'camel'`` + test prefix; defaults to ``'camel'``. DEPRECATED: use + ``php_unit_method_casing`` fixer instead - ``style`` (``'annotation'``, ``'prefix'``): whether to use the @test annotation or not; defaults to ``'prefix'`` diff --git a/src/Fixer/PhpUnit/PhpUnitMethodCasingFixer.php b/src/Fixer/PhpUnit/PhpUnitMethodCasingFixer.php new file mode 100644 index 00000000000..9565744bf6a --- /dev/null +++ b/src/Fixer/PhpUnit/PhpUnitMethodCasingFixer.php @@ -0,0 +1,274 @@ + + * Dariusz Rumiński + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace PhpCsFixer\Fixer\PhpUnit; + +use PhpCsFixer\AbstractFixer; +use PhpCsFixer\DocBlock\DocBlock; +use PhpCsFixer\DocBlock\Line; +use PhpCsFixer\Fixer\ConfigurationDefinitionFixerInterface; +use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver; +use PhpCsFixer\FixerConfiguration\FixerOptionBuilder; +use PhpCsFixer\FixerDefinition\CodeSample; +use PhpCsFixer\FixerDefinition\FixerDefinition; +use PhpCsFixer\Indicator\PhpUnitTestCaseIndicator; +use PhpCsFixer\Preg; +use PhpCsFixer\Tokenizer\Token; +use PhpCsFixer\Tokenizer\Tokens; +use PhpCsFixer\Tokenizer\TokensAnalyzer; +use PhpCsFixer\Utils; + +/** + * @author Filippo Tessarotto + */ +final class PhpUnitMethodCasingFixer extends AbstractFixer implements ConfigurationDefinitionFixerInterface +{ + /** + * @internal + */ + const CAMEL_CASE = 'camel_case'; + + /** + * @internal + */ + const SNAKE_CASE = 'snake_case'; + + /** + * {@inheritdoc} + */ + public function getDefinition() + { + return new FixerDefinition( + 'Enforce camel (or snake) case for PHPUnit test methods, following configuration.', + [ + new CodeSample( + ' self::SNAKE_CASE] + ), + ] + ); + } + + /** + * {@inheritdoc} + */ + public function isCandidate(Tokens $tokens) + { + return $tokens->isAllTokenKindsFound([T_CLASS, T_FUNCTION]); + } + + /** + * {@inheritdoc} + */ + protected function applyFix(\SplFileInfo $file, Tokens $tokens) + { + $phpUnitTestCaseIndicator = new PhpUnitTestCaseIndicator(); + foreach ($phpUnitTestCaseIndicator->findPhpUnitClasses($tokens) as $indexes) { + $this->applyCasing($tokens, $indexes[0], $indexes[1]); + } + } + + /** + * {@inheritdoc} + */ + protected function createConfigurationDefinition() + { + return new FixerConfigurationResolver([ + (new FixerOptionBuilder('case', 'Apply camel or snake case to test methods')) + ->setAllowedValues([self::CAMEL_CASE, self::SNAKE_CASE]) + ->setDefault(self::CAMEL_CASE) + ->getOption(), + ]); + } + + /** + * @param Tokens $tokens + * @param int $startIndex + * @param int $endIndex + */ + private function applyCasing(Tokens $tokens, $startIndex, $endIndex) + { + for ($index = $endIndex - 1; $index > $startIndex; --$index) { + if (!$this->isTestMethod($tokens, $index)) { + continue; + } + + $functionNameIndex = $tokens->getNextMeaningfulToken($index); + $functionName = $tokens[$functionNameIndex]->getContent(); + $newFunctionName = $this->updateMethodCasing($functionName); + + if ($newFunctionName !== $functionName) { + $tokens[$functionNameIndex] = new Token([T_STRING, $newFunctionName]); + } + + $docBlockIndex = $this->getDocBlockIndex($tokens, $index); + if ($this->hasDocBlock($tokens, $index)) { + $this->updateDocBlock($tokens, $docBlockIndex); + } + } + } + + /** + * @param string $functionName + * + * @return string + */ + private function updateMethodCasing($functionName) + { + if (self::CAMEL_CASE === $this->configuration['case']) { + $newFunctionName = $functionName; + $newFunctionName = ucwords($newFunctionName, '_'); + $newFunctionName = str_replace('_', '', $newFunctionName); + $newFunctionName = lcfirst($newFunctionName); + } else { + $newFunctionName = Utils::camelCaseToUnderscore($functionName); + } + + return $newFunctionName; + } + + /** + * @param Tokens $tokens + * @param int $index + * + * @return bool + */ + private function isTestMethod(Tokens $tokens, $index) + { + // Check if we are dealing with a (non abstract, non lambda) function + if (!$this->isMethod($tokens, $index)) { + return false; + } + + // if the function name starts with test it's a test + $functionNameIndex = $tokens->getNextMeaningfulToken($index); + $functionName = $tokens[$functionNameIndex]->getContent(); + + if ($this->startsWith('test', $functionName)) { + return true; + } + // If the function doesn't have test in its name, and no doc block, it's not a test + if (!$this->hasDocBlock($tokens, $index)) { + return false; + } + + $docBlockIndex = $this->getDocBlockIndex($tokens, $index); + $doc = $tokens[$docBlockIndex]->getContent(); + if (false === strpos($doc, '@test')) { + return false; + } + + return true; + } + + /** + * @param Tokens $tokens + * @param int $index + * + * @return bool + */ + private function isMethod(Tokens $tokens, $index) + { + $tokensAnalyzer = new TokensAnalyzer($tokens); + + return $tokens[$index]->isGivenKind(T_FUNCTION) && !$tokensAnalyzer->isLambda($index); + } + + /** + * @param string $needle + * @param string $haystack + * + * @return bool + */ + private function startsWith($needle, $haystack) + { + return substr($haystack, 0, strlen($needle)) === $needle; + } + + /** + * @param Tokens $tokens + * @param int $index + * + * @return bool + */ + private function hasDocBlock(Tokens $tokens, $index) + { + $docBlockIndex = $this->getDocBlockIndex($tokens, $index); + + return $tokens[$docBlockIndex]->isGivenKind(T_DOC_COMMENT); + } + + /** + * @param Tokens $tokens + * @param int $index + * + * @return int + */ + private function getDocBlockIndex(Tokens $tokens, $index) + { + do { + $index = $tokens->getPrevNonWhitespace($index); + } while ($tokens[$index]->isGivenKind([T_PUBLIC, T_PROTECTED, T_PRIVATE, T_FINAL, T_ABSTRACT, T_COMMENT])); + + return $index; + } + + /** + * @param Tokens $tokens + * @param int $docBlockIndex + */ + private function updateDocBlock(Tokens $tokens, $docBlockIndex) + { + $doc = new DocBlock($tokens[$docBlockIndex]->getContent()); + $lines = $doc->getLines(); + + $docBlockNeesUpdate = false; + for ($inc = 0; $inc < \count($lines); ++$inc) { + $lineContent = $lines[$inc]->getContent(); + if (false === strpos($lineContent, '@depends')) { + continue; + } + + $newLineContent = Preg::replaceCallback('/(@depends\s+)(.+)(\b)/', function (array $matches) { + return sprintf( + '%s%s%s', + $matches[1], + $this->updateMethodCasing($matches[2]), + $matches[3] + ); + }, $lineContent); + + if ($newLineContent !== $lineContent) { + $lines[$inc] = new Line($newLineContent); + $docBlockNeesUpdate = true; + } + } + + if ($docBlockNeesUpdate) { + $lines = implode($lines); + $tokens[$docBlockIndex] = new Token([T_DOC_COMMENT, $lines]); + } + } +} diff --git a/src/Fixer/PhpUnit/PhpUnitTestAnnotationFixer.php b/src/Fixer/PhpUnit/PhpUnitTestAnnotationFixer.php index 839d3168a64..8286dc3dfa6 100644 --- a/src/Fixer/PhpUnit/PhpUnitTestAnnotationFixer.php +++ b/src/Fixer/PhpUnit/PhpUnitTestAnnotationFixer.php @@ -108,6 +108,7 @@ protected function createConfigurationDefinition() (new FixerOptionBuilder('case', 'Whether to camel or snake case when adding the test prefix')) ->setAllowedValues(['camel', 'snake']) ->setDefault('camel') + ->setDeprecationMessage('Use `php_unit_method_casing` fixer instead.') ->getOption(), ]); } diff --git a/tests/AutoReview/FixerFactoryTest.php b/tests/AutoReview/FixerFactoryTest.php index d227df6ee57..717eec6823e 100644 --- a/tests/AutoReview/FixerFactoryTest.php +++ b/tests/AutoReview/FixerFactoryTest.php @@ -208,6 +208,7 @@ public function provideFixersPriorityCases() [$fixers['void_return'], $fixers['phpdoc_no_empty_return']], [$fixers['void_return'], $fixers['return_type_declaration']], [$fixers['php_unit_test_annotation'], $fixers['no_empty_phpdoc']], + [$fixers['php_unit_test_annotation'], $fixers['php_unit_method_casing']], [$fixers['php_unit_test_annotation'], $fixers['phpdoc_trim']], [$fixers['no_alternative_syntax'], $fixers['braces']], [$fixers['no_alternative_syntax'], $fixers['elseif']], diff --git a/tests/Fixer/PhpUnit/PhpUnitMethodCasingFixerTest.php b/tests/Fixer/PhpUnit/PhpUnitMethodCasingFixerTest.php new file mode 100644 index 00000000000..b3f9d5efc4c --- /dev/null +++ b/tests/Fixer/PhpUnit/PhpUnitMethodCasingFixerTest.php @@ -0,0 +1,130 @@ + + * Dariusz Rumiński + * + * This source file is subject to the MIT license that is bundled + * with this source code in the file LICENSE. + */ + +namespace PhpCsFixer\Tests\Fixer\PhpUnit; + +use PhpCsFixer\Fixer\PhpUnit\PhpUnitMethodCasingFixer; +use PhpCsFixer\Tests\Test\AbstractFixerTestCase; + +/** + * @author Filippo Tessarotto + * + * @internal + * + * @covers \PhpCsFixer\Fixer\PhpUnit\PhpUnitMethodCasingFixer + */ +final class PhpUnitMethodCasingFixerTest extends AbstractFixerTestCase +{ + /** + * @dataProvider provideFixCases + * + * @param string $expected + * @param null|string $input + */ + public function testFixToCamelCase($expected, $input = null) + { + $this->doTest($expected, $input); + } + + /** + * @dataProvider provideFixCases + * + * @param mixed $camelExpected + * @param null|mixed $camelInput + */ + public function testFixToSnakeCase($camelExpected, $camelInput = null) + { + if (null === $camelInput) { + $expected = $camelExpected; + $input = $camelInput; + } else { + $expected = $camelInput; + $input = $camelExpected; + } + + $this->fixer->configure(['case' => PhpUnitMethodCasingFixer::SNAKE_CASE]); + $this->doTest($expected, $input); + } + + /** + * @return array + */ + public function provideFixCases() + { + return [ + 'skip non phpunit methods' => [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ - ' 'snake'], - ], 'Annotation is added, and it is snake case' => [ ' 'annotation'], ], - 'Annotation is removed, and it is snake case' => [ - ' 'snake'], - ], 'Annotation gets added, it has an @depends, and we use snake case' => [ ' 'annotation'], ], - 'Annotation gets removed, it has an @depends and we use camel case' => [ - ' 'snake'], - ], 'Class has both camel and snake case, annotated functions and not, and wants to add annotations' => [ ' 'annotation'], ], - 'Annotation has to be removed from multiple functions and we use snake case' => [ - ' 'snake'], - ], 'Class with big doc blocks and multiple functions has to remove annotations' => [ ' [ - ' 'snake'], - ], 'Test Annotation has to be removed, but its just one line' => [ 'fixer->configure($config); + $this->doTest($expected, $input); + } + + /** + * @return array + */ + public function provideFixLegacyCaseOptionCases() + { + return [ + 'Annotation is removed, the function is one word and we want it to use snake case' => [ + ' 'snake'], + ], + 'Annotation is removed, and it is snake case' => [ + ' 'snake'], + ], + 'Annotation gets removed, it has an @depends and we use camel case' => [ + ' 'snake'], + ], + 'Annotation has to be removed from multiple functions and we use snake case' => [ + ' 'snake'], + ], + 'Class with big doc blocks and multiple functions has to remove annotations, but its snake case' => [ + ' 'snake'], + ], + ]; + } + /** * @param string $expected * @param null|string $input diff --git a/tests/Fixtures/Integration/priority/php_unit_test_annotation,php_unit_method_casing.test b/tests/Fixtures/Integration/priority/php_unit_test_annotation,php_unit_method_casing.test new file mode 100644 index 00000000000..c1a304fc6e2 --- /dev/null +++ b/tests/Fixtures/Integration/priority/php_unit_test_annotation,php_unit_method_casing.test @@ -0,0 +1,25 @@ +--TEST-- +Integration of fixers: php_unit_test_annotation,php_unit_method_casing +--RULESET-- +{"php_unit_test_annotation": true, "php_unit_method_casing" : {"case": "snake_case"}} +--EXPECT-- +