From 457106878bafb05303f6fc23e489b2279d2d94ac Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 28 Apr 2024 00:06:26 +0200 Subject: [PATCH] Introduce HashQuote rule --- rector.php | 2 - src/Rules/String/HashQuoteRule.php | 107 ++++++++++++++++++ src/Token/Tokenizer.php | 14 ++- .../HashQuote/HashQuoteRuleTest.fixed.twig | 17 +++ .../String/HashQuote/HashQuoteRuleTest.php | 41 +++++++ .../String/HashQuote/HashQuoteRuleTest.twig | 17 +++ .../HashQuoteRuleTest.without.fixed.twig | 17 +++ 7 files changed, 207 insertions(+), 8 deletions(-) create mode 100644 src/Rules/String/HashQuoteRule.php create mode 100644 tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig create mode 100644 tests/Rules/String/HashQuote/HashQuoteRuleTest.php create mode 100644 tests/Rules/String/HashQuote/HashQuoteRuleTest.twig create mode 100644 tests/Rules/String/HashQuote/HashQuoteRuleTest.without.fixed.twig diff --git a/rector.php b/rector.php index 18fe6459..f6e2bef9 100644 --- a/rector.php +++ b/rector.php @@ -3,7 +3,6 @@ declare(strict_types=1); use Rector\Config\RectorConfig; -use Rector\Privatization\Rector\Class_\FinalizeClassesWithoutChildrenRector; use Rector\Privatization\Rector\ClassMethod\PrivatizeFinalClassMethodRector; use Rector\Privatization\Rector\Property\PrivatizeFinalClassPropertyRector; use Rector\Set\ValueObject\LevelSetList; @@ -21,7 +20,6 @@ LevelSetList::UP_TO_PHP_80, ]); $rectorConfig->rules([ - FinalizeClassesWithoutChildrenRector::class, PrivatizeFinalClassMethodRector::class, PrivatizeFinalClassPropertyRector::class, ]); diff --git a/src/Rules/String/HashQuoteRule.php b/src/Rules/String/HashQuoteRule.php new file mode 100644 index 00000000..860b640c --- /dev/null +++ b/src/Rules/String/HashQuoteRule.php @@ -0,0 +1,107 @@ + $this->useQuote, + ]; + } + + protected function process(int $tokenPosition, array $tokens): void + { + $token = $tokens[$tokenPosition]; + if (!$this->isTokenMatching($token, Token::PUNCTUATION_TYPE, ':')) { + return; + } + + $previous = $this->findPrevious(Token::EMPTY_TOKENS, $tokens, $tokenPosition - 1, true); + Assert::notFalse($previous, 'A punctuation cannot be the first token.'); + + if ($this->useQuote) { + $this->nameShouldBeString($previous, $tokens); + } else { + $this->stringShouldBeName($previous, $tokens); + } + } + + /** + * @param array $tokens + */ + private function nameShouldBeString(int $tokenPosition, array $tokens): void + { + $token = $tokens[$tokenPosition]; + + $value = $token->getValue(); + $error = sprintf('The hash key "%s" should be quoted.', $value); + + if ($this->isTokenMatching($token, Token::NUMBER_TYPE)) { + // A value like `012` or `12.3` is cast to `12` by twig, + // so we let the developer chose the right value. + $fixable = $this->isInteger($value); + } elseif ($this->isTokenMatching($token, Token::NAME_TYPE)) { + $fixable = true; + } else { + return; + } + + $fixer = $fixable + ? $this->addFixableError($error, $token) + : $this->addError($error, $token); + + if ($fixer instanceof FixerInterface) { + $success = $fixer->replaceToken($tokenPosition, '\''.$value.'\''); + } + } + + /** + * @param array $tokens + */ + private function stringShouldBeName(int $tokenPosition, array $tokens): void + { + $token = $tokens[$tokenPosition]; + if (!$this->isTokenMatching($token, Token::STRING_TYPE)) { + return; + } + + $expectedValue = substr($token->getValue(), 1, -1); + if ( + !$this->isInteger($expectedValue) + && 1 !== preg_match('/^'.Tokenizer::NAME_PATTERN.'$/', $expectedValue) + ) { + return; + } + + $fixer = $this->addFixableError( + sprintf('The hash key "%s" does not require to be quoted.', $expectedValue), + $token + ); + if (null !== $fixer) { + $fixer->replaceToken($tokenPosition, $expectedValue); + } + } + + private function isInteger(string $value): bool + { + return $value === (string) (int) $value; + } +} diff --git a/src/Token/Tokenizer.php b/src/Token/Tokenizer.php index c429148c..f105a345 100644 --- a/src/Token/Tokenizer.php +++ b/src/Token/Tokenizer.php @@ -22,8 +22,10 @@ final class Tokenizer implements TokenizerInterface private const STATE_INTERPOLATION = 4; private const STATE_COMMENT = 5; - private const SQ_STRING_PART = '[^\'\\\\]*(?:\\\\.[^\'\\\\]*)*'; - private const DQ_STRING_PART = '[^#"\\\\]*(?:(?:\\\\.|#(?!\{))[^#"\\\\]*)*'; + public const NAME_PATTERN = '[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*'; + public const NUMBER_PATTERN = '[0-9]+(?:\.[0-9]+)?([Ee][+\-][0-9]+)?'; + private const SQ_STRING_PATTERN = '[^\'\\\\]*(?:\\\\.[^\'\\\\]*)*'; + private const DQ_STRING_PATTERN = '[^#"\\\\]*(?:(?:\\\\.|#(?!\{))[^#"\\\\]*)*'; private const REGEX_EXPRESSION_START = '/({%|{#|{{)[-~]?/'; private const REGEX_BLOCK_END = '/[-~]?%}/A'; @@ -33,10 +35,10 @@ final class Tokenizer implements TokenizerInterface private const REGEX_INTERPOLATION_START = '/#{/A'; private const REGEX_INTERPOLATION_END = '/}/A'; - private const REGEX_NAME = '/[a-zA-Z_\x7f-\xff][a-zA-Z0-9_\x7f-\xff]*/A'; - private const REGEX_NUMBER = '/[0-9]+(?:\.[0-9]+)?([Ee][+\-][0-9]+)?/A'; - private const REGEX_STRING = '/"('.self::DQ_STRING_PART.')"|\'('.self::SQ_STRING_PART.')\'/As'; - private const REGEX_DQ_STRING_PART = '/'.self::DQ_STRING_PART.'/As'; + private const REGEX_NAME = '/'.self::NAME_PATTERN.'/A'; + private const REGEX_NUMBER = '/'.self::NUMBER_PATTERN.'/A'; + private const REGEX_STRING = '/"('.self::DQ_STRING_PATTERN.')"|\'('.self::SQ_STRING_PATTERN.')\'/As'; + private const REGEX_DQ_STRING_PART = '/'.self::DQ_STRING_PATTERN.'/As'; private const REGEX_DQ_STRING_DELIM = '/"/A'; /** diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig b/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig new file mode 100644 index 00000000..09554751 --- /dev/null +++ b/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig @@ -0,0 +1,17 @@ +{% set foo = 42 %} +{% set expression = {(foo): 'foo', (1 + 1): 'bar', (foo ~ 'b'): 'baz'} %} + +{% set conditional1 = true ? 'foo' : 'bar' %} +{% set conditional2 = 'foo' ?: 'bar' %} + +{% set text = {'a': a} %} +{% set text = {'a': a} %} + +{% set number = {'123': a} %} +{% set number = {'123': a} %} +{% set numberWithZero = {0123: a} %} {# It's the same than `{123: a}` #} +{% set numberWithZero = {'0123': a} %} +{% set float = {12.3: a} %} {# It's the same than `{123: a}` #} +{% set float = {'12.3': a} %} + +{% set needQuote = {'data-foo': a} %} diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.php b/tests/Rules/String/HashQuote/HashQuoteRuleTest.php new file mode 100644 index 00000000..809333cc --- /dev/null +++ b/tests/Rules/String/HashQuote/HashQuoteRuleTest.php @@ -0,0 +1,41 @@ + true], + (new HashQuoteRule())->getConfiguration() + ); + static::assertSame( + ['useQuote' => false], + (new HashQuoteRule(false))->getConfiguration() + ); + } + + public function testRule(): void + { + $this->checkRule(new HashQuoteRule(), [ + 'HashQuote.Error:8:16' => 'The hash key "a" should be quoted.', + 'HashQuote.Error:10:18' => 'The hash key "123" should be quoted.', + 'HashQuote.Error:12:26' => 'The hash key "0123" should be quoted.', + 'HashQuote.Error:14:17' => 'The hash key "12.3" should be quoted.', + ]); + } + + public function testRuleWithoutSingleQuote(): void + { + $this->checkRule(new HashQuoteRule(false), [ + 'HashQuote.Error:7:16' => 'The hash key "a" does not require to be quoted.', + 'HashQuote.Error:11:18' => 'The hash key "123" does not require to be quoted.', + ], fixedFilePath: __DIR__.'/HashQuoteRuleTest.without.fixed.twig'); + } +} diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig b/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig new file mode 100644 index 00000000..3fd48af7 --- /dev/null +++ b/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig @@ -0,0 +1,17 @@ +{% set foo = 42 %} +{% set expression = {(foo): 'foo', (1 + 1): 'bar', (foo ~ 'b'): 'baz'} %} + +{% set conditional1 = true ? 'foo' : 'bar' %} +{% set conditional2 = 'foo' ?: 'bar' %} + +{% set text = {'a': a} %} +{% set text = {a: a} %} + +{% set number = {123: a} %} +{% set number = {'123': a} %} +{% set numberWithZero = {0123: a} %} {# It's the same than `{123: a}` #} +{% set numberWithZero = {'0123': a} %} +{% set float = {12.3: a} %} {# It's the same than `{123: a}` #} +{% set float = {'12.3': a} %} + +{% set needQuote = {'data-foo': a} %} diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.without.fixed.twig b/tests/Rules/String/HashQuote/HashQuoteRuleTest.without.fixed.twig new file mode 100644 index 00000000..bd9ff2c8 --- /dev/null +++ b/tests/Rules/String/HashQuote/HashQuoteRuleTest.without.fixed.twig @@ -0,0 +1,17 @@ +{% set foo = 42 %} +{% set expression = {(foo): 'foo', (1 + 1): 'bar', (foo ~ 'b'): 'baz'} %} + +{% set conditional1 = true ? 'foo' : 'bar' %} +{% set conditional2 = 'foo' ?: 'bar' %} + +{% set text = {a: a} %} +{% set text = {a: a} %} + +{% set number = {123: a} %} +{% set number = {123: a} %} +{% set numberWithZero = {0123: a} %} {# It's the same than `{123: a}` #} +{% set numberWithZero = {'0123': a} %} +{% set float = {12.3: a} %} {# It's the same than `{123: a}` #} +{% set float = {'12.3': a} %} + +{% set needQuote = {'data-foo': a} %}