Skip to content

Commit

Permalink
Introduce HashQuote rule
Browse files Browse the repository at this point in the history
  • Loading branch information
VincentLanglet committed Apr 30, 2024
1 parent 8b843a1 commit 4571068
Show file tree
Hide file tree
Showing 7 changed files with 207 additions and 8 deletions.
2 changes: 0 additions & 2 deletions rector.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -21,7 +20,6 @@
LevelSetList::UP_TO_PHP_80,
]);
$rectorConfig->rules([
FinalizeClassesWithoutChildrenRector::class,
PrivatizeFinalClassMethodRector::class,
PrivatizeFinalClassPropertyRector::class,
]);
Expand Down
107 changes: 107 additions & 0 deletions src/Rules/String/HashQuoteRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<?php

declare(strict_types=1);

namespace TwigCsFixer\Rules\String;

use TwigCsFixer\Rules\AbstractFixableRule;
use TwigCsFixer\Rules\ConfigurableRuleInterface;
use TwigCsFixer\Runner\FixerInterface;
use TwigCsFixer\Token\Token;
use TwigCsFixer\Token\Tokenizer;
use Webmozart\Assert\Assert;

/**
* Ensures that hashes key use quotes (or not).
*/
final class HashQuoteRule extends AbstractFixableRule implements ConfigurableRuleInterface
{
public function __construct(private bool $useQuote = true)
{
}

public function getConfiguration(): array
{
return [
'useQuote' => $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<int, Token> $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<int, Token> $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;
}
}
14 changes: 8 additions & 6 deletions src/Token/Tokenizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

/**
Expand Down
17 changes: 17 additions & 0 deletions tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig
Original file line number Diff line number Diff line change
@@ -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} %}
41 changes: 41 additions & 0 deletions tests/Rules/String/HashQuote/HashQuoteRuleTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<?php

declare(strict_types=1);

namespace TwigCsFixer\Tests\Rules\String\HashQuote;

use TwigCsFixer\Rules\String\HashQuoteRule;
use TwigCsFixer\Tests\Rules\AbstractRuleTestCase;

class HashQuoteRuleTest extends AbstractRuleTestCase
{
public function testConfiguration(): void
{
static::assertSame(
['useQuote' => 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');
}
}
17 changes: 17 additions & 0 deletions tests/Rules/String/HashQuote/HashQuoteRuleTest.twig
Original file line number Diff line number Diff line change
@@ -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} %}
17 changes: 17 additions & 0 deletions tests/Rules/String/HashQuote/HashQuoteRuleTest.without.fixed.twig
Original file line number Diff line number Diff line change
@@ -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} %}

0 comments on commit 4571068

Please sign in to comment.