diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b005b900..f6415266 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -23,6 +23,8 @@ jobs: - if: github.event_name != 'pull_request' run: rm ./.dev-tools/composer.lock - run: composer update --no-progress + - run: "sed -i 's#constant: 0#constant: 100#g' .dev-tools/phpstan.neon" + - run: composer apply-typed_class_constant - run: composer analyse test: diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php index 9e9734e5..5c9bc939 100644 --- a/.php-cs-fixer.php +++ b/.php-cs-fixer.php @@ -20,6 +20,7 @@ use PhpCsFixerCustomFixers\Fixer\NoSuperfluousConcatenationFixer; use PhpCsFixerCustomFixers\Fixer\PhpdocOnlyAllowedAnnotationsFixer; use PhpCsFixerCustomFixers\Fixer\PromotedConstructorPropertyFixer; +use PhpCsFixerCustomFixers\Fixer\TypedClassConstantFixer; use PhpCsFixerCustomFixers\Fixers; // sanity check @@ -54,6 +55,7 @@ unset($rules['modernize_strpos']); // TODO: remove when dropping support to PHP <8.0 unset($rules['php_unit_attributes']); // TODO: remove when dropping support to PHP <8.0 unset($rules[PromotedConstructorPropertyFixer::name()]); // TODO: remove when dropping support to PHP <8.0 +unset($rules[TypedClassConstantFixer::name()]); // TODO: remove when dropping support to PHP <8.3 $rules['trailing_comma_in_multiline'] = ['after_heredoc' => true, 'elements' => ['arguments', 'arrays']]; // TODO: remove when dropping support to PHP <8.0 $rules[PhpdocOnlyAllowedAnnotationsFixer::name()]['elements'][] = 'phpstan-type'; diff --git a/CHANGELOG.md b/CHANGELOG.md index cb802792..47dee370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ # CHANGELOG for PHP CS Fixer: custom fixers +## v3.26.0 +- Add TypedClassConstantFixer + ## v3.25.0 - Add ForeachUseValueFixer - Add NoUselessWriteVisibilityFixer diff --git a/README.md b/README.md index f680dde7..bcf82d7f 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ [![Latest stable version](https://img.shields.io/packagist/v/kubawerlos/php-cs-fixer-custom-fixers.svg?label=current%20version)](https://packagist.org/packages/kubawerlos/php-cs-fixer-custom-fixers) [![PHP version](https://img.shields.io/packagist/php-v/kubawerlos/php-cs-fixer-custom-fixers.svg)](https://php.net) [![License](https://img.shields.io/github/license/kubawerlos/php-cs-fixer-custom-fixers.svg)](LICENSE) -![Tests](https://img.shields.io/badge/tests-3691-brightgreen.svg) +![Tests](https://img.shields.io/badge/tests-3750-brightgreen.svg) [![Downloads](https://img.shields.io/packagist/dt/kubawerlos/php-cs-fixer-custom-fixers.svg)](https://packagist.org/packages/kubawerlos/php-cs-fixer-custom-fixers) [![CI status](https://github.com/kubawerlos/php-cs-fixer-custom-fixers/actions/workflows/ci.yaml/badge.svg)](https://github.com/kubawerlos/php-cs-fixer-custom-fixers/actions/workflows/ci.yaml) @@ -724,6 +724,18 @@ The string key of an array or generator must be trimmed and have no double space ]; ``` +#### TypedClassConstantFixer +Class constants must have a type. +```diff + isAllTokenKindsFound([\T_CLASS, \T_CONST]); + } + + public function isRisky(): bool + { + return false; + } + + public function fix(\SplFileInfo $file, Tokens $tokens): void + { + for ($index = $tokens->count() - 1; $index > 0; $index--) { + if (!$tokens[$index]->isGivenKind(\T_CLASS)) { + continue; + } + + $openParenthesisIndex = $tokens->getNextTokenOfKind($index, ['{']); + \assert(\is_int($openParenthesisIndex)); + + $closeParenthesisIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $openParenthesisIndex); + + self::fixClass($tokens, $openParenthesisIndex, $closeParenthesisIndex); + } + } + + private static function fixClass(Tokens $tokens, int $openParenthesisIndex, int $closeParenthesisIndex): void + { + for ($index = $closeParenthesisIndex; $index > $openParenthesisIndex; $index--) { + if (!$tokens[$index]->isGivenKind(\T_CONST)) { + continue; + } + + $constantNameIndex = $tokens->getNextMeaningfulToken($index); + \assert(\is_int($constantNameIndex)); + + $assignmentIndex = $tokens->getNextMeaningfulToken($constantNameIndex); + \assert(\is_int($assignmentIndex)); + + if (!$tokens[$assignmentIndex]->equals('=')) { + continue; + } + + $expressionStartIndex = $tokens->getNextMeaningfulToken($assignmentIndex); + \assert(\is_int($expressionStartIndex)); + + if ($tokens[$expressionStartIndex]->isGivenKind(\T_NS_SEPARATOR)) { + $expressionStartIndex = $tokens->getNextMeaningfulToken($expressionStartIndex); + \assert(\is_int($expressionStartIndex)); + } + + $type = self::getTypeOfExpression($tokens, $expressionStartIndex); + + $tokens->insertAt( + $constantNameIndex, + [ + new Token([$type === 'array' ? CT::T_ARRAY_TYPEHINT : \T_STRING, $type]), + new Token([\T_WHITESPACE, ' ']), + ], + ); + } + } + + private static function getTypeOfExpression(Tokens $tokens, int $index): string + { + $semicolonIndex = $tokens->getNextTokenOfKind($index, [';']); + \assert(\is_int($semicolonIndex)); + + $beforeSemicolonIndex = $tokens->getPrevMeaningfulToken($semicolonIndex); + \assert(\is_int($beforeSemicolonIndex)); + + $foundKinds = []; + + $questionMarkCount = 0; + do { + if ($questionMarkCount > 1) { + return 'mixed'; + } + $kind = $tokens[$index]->getId() ?? $tokens[$index]->getContent(); + if ($kind === '?') { + $questionMarkCount++; + $foundKinds = []; + continue; + } + $foundKinds[] = $kind; + + $index = $tokens->getNextMeaningfulToken($index); + \assert(\is_int($index)); + } while ($index < $semicolonIndex); + + if ($foundKinds === [\T_STRING]) { + $lowercasedContent = \strtolower($tokens[$beforeSemicolonIndex]->getContent()); + if (\in_array($lowercasedContent, ['false', 'true', 'null'], true)) { + return $lowercasedContent; + } + } + + return self::getTypeOfExpressionForTokenKinds($foundKinds); + } + + /** + * @param list $tokenKinds + */ + private static function getTypeOfExpressionForTokenKinds(array $tokenKinds): string + { + if (self::isOfTypeBasedOnKinds($tokenKinds, [], [\T_ARRAY, CT::T_ARRAY_SQUARE_BRACE_OPEN])) { + return 'array'; + } + + if (self::isOfTypeBasedOnKinds($tokenKinds, self::INTEGER_KINDS, [])) { + return 'int'; + } + + if (self::isOfTypeBasedOnKinds($tokenKinds, self::FLOAT_KINDS, [])) { + return 'float'; + } + + if (self::isOfTypeBasedOnKinds($tokenKinds, self::STRING_KINDS, ['.', CT::T_CLASS_CONSTANT])) { + return 'string'; + } + + return 'mixed'; + } + + /** + * @param list $expressionTokenKinds + * @param list $expectedKinds + * @param list $instantWinners + */ + private static function isOfTypeBasedOnKinds( + array $expressionTokenKinds, + array $expectedKinds, + array $instantWinners + ): bool { + foreach ($expressionTokenKinds as $index => $expressionTokenKind) { + if (\in_array($expressionTokenKind, $instantWinners, true)) { + return true; + } + if (\in_array($expressionTokenKind, $expectedKinds, true)) { + unset($expressionTokenKinds[$index]); + } + } + + return $expressionTokenKinds === []; + } +} diff --git a/tests/Fixer/TypedClassConstantFixerTest.php b/tests/Fixer/TypedClassConstantFixerTest.php new file mode 100644 index 00000000..c1d85a9a --- /dev/null +++ b/tests/Fixer/TypedClassConstantFixerTest.php @@ -0,0 +1,345 @@ += 8.3 + */ + public function testFix(string $expected, ?string $input = null): void + { + $this->doTest($expected, $input); + } + + /** + * @return iterable + */ + public static function provideFixCases(): iterable + { + yield 'non-class constants are ignored' => [' [ + ' [ + ' [ + ' 2] + array(3 => 4) + [5 => 6]; }', + ' 2] + array(3 => 4) + [5 => 6]; }', + ]; + + yield 'array as sum of long syntax array and constant' => [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + ' [ + '> 1; }', + '> 1; }', + ]; + + yield 'float' => [ + ' [ + ' [ + ' [ + ' [ + <<<'PHP' + [ + ' [ + " [ + ' [ + <<<'PHP' + [ + <<<'PHP' + [ + <<<'PHP' + [ + <<<'PHP' + [ + ' [ + ' [ + <<<'PHP' + [ + ' [ + ' [ + ' [ + <<<'PHP' + [ + <<<'PHP' + [ + '= 8.1 + * @requires PHP >= 8.3 */ final class PriorityTest extends TestCase { diff --git a/tests/priority_fixtures/single_class_element_per_statement,Custom_typed_class_constant.test b/tests/priority_fixtures/single_class_element_per_statement,Custom_typed_class_constant.test new file mode 100644 index 00000000..fabde4a5 --- /dev/null +++ b/tests/priority_fixtures/single_class_element_per_statement,Custom_typed_class_constant.test @@ -0,0 +1,14 @@ +--CONFIGURATION-- +{ "single_class_element_per_statement": true, "PhpCsFixerCustomFixers/typed_class_constant": true } +--EXPECTED-- +