Repository: VincentLanglet/Twig-CS-Fixer (php)

Issue:
[RFC] Rule whish list
Hello, 

I have a wish list 🎁 :) Feel free to close it if it's not in your scope!

## Remove useless quote in hashes

Do you think it would be doable to apply this diff

```diff
- {{ include('hello.html.twig', { 'publications': alreadyPublished }) }}
+ {{ include('hello.html.twig', { publications: alreadyPublished }) }}
```

## Use the same quote everywhere

```diff
- {{ include("hello.html.twig" }}
+ {{ include('hello.html.twig' }}
```

Note: It would depends on the configuration 



Pull request description:
Introduce HashQuote rule
Closes #211

Patch:
diff --git a/rector.php b/rector.php
index 18fe645..f6e2bef 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 @@ return static function (RectorConfig $rectorConfig): void {
         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 0000000..860b640
--- /dev/null
+++ b/src/Rules/String/HashQuoteRule.php
@@ -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;
+    }
+}
diff --git a/src/Rules/String/SingleQuoteRule.php b/src/Rules/String/SingleQuoteRule.php
index 705d6c4..366b310 100644
--- a/src/Rules/String/SingleQuoteRule.php
+++ b/src/Rules/String/SingleQuoteRule.php
@@ -20,7 +20,7 @@ final class SingleQuoteRule extends AbstractFixableRule implements ConfigurableR
     public function getConfiguration(): array
     {
         return [
-            'skip_string_containing_single_quote' => $this->skipStringContainingSingleQuote,
+            'skipStringContainingSingleQuote' => $this->skipStringContainingSingleQuote,
         ];
     }
 
diff --git a/src/Rules/Variable/VariableNameRule.php b/src/Rules/Variable/VariableNameRule.php
index 4f1e7db..cc19da1 100644
--- a/src/Rules/Variable/VariableNameRule.php
+++ b/src/Rules/Variable/VariableNameRule.php
@@ -23,14 +23,17 @@ final class VariableNameRule extends AbstractRule implements ConfigurableRuleInt
     /**
      * @param self::* $case
      */
-    public function __construct(private string $case = self::SNAKE_CASE)
-    {
+    public function __construct(
+        private string $case = self::SNAKE_CASE,
+        private string $optionalPrefix = '',
+    ) {
     }
 
     public function getConfiguration(): array
     {
         return [
             'case' => $this->case,
+            'optionalPrefix' => $this->optionalPrefix,
         ];
     }
 
@@ -60,6 +63,12 @@ final class VariableNameRule extends AbstractRule implements ConfigurableRuleInt
     private function validateVariable(Token $token): void
     {
         $name = $token->getValue();
+        $prefix = '';
+        if (str_starts_with($name, $this->optionalPrefix)) {
+            $prefix = $this->optionalPrefix;
+            $name = substr($name, \strlen($this->optionalPrefix));
+        }
+
         $expected = match ($this->case) {
             self::SNAKE_CASE => StringUtil::toSnakeCase($name),
             self::CAMEL_CASE => StringUtil::toCamelCase($name),
@@ -68,7 +77,7 @@ final class VariableNameRule extends AbstractRule implements ConfigurableRuleInt
 
         if ($expected !== $name) {
             $this->addError(
-                sprintf('The var name must use %s; expected %s.', $this->case, $expected),
+                sprintf('The var name must use %s; expected %s.', $this->case, $prefix.$expected),
                 $token,
             );
         }
diff --git a/src/Rules/Whitespace/IndentRule.php b/src/Rules/Whitespace/IndentRule.php
index 47e7569..162d698 100644
--- a/src/Rules/Whitespace/IndentRule.php
+++ b/src/Rules/Whitespace/IndentRule.php
@@ -20,7 +20,7 @@ final class IndentRule extends AbstractFixableRule implements ConfigurableRuleIn
     public function getConfiguration(): array
     {
         return [
-            'space_ratio' => $this->spaceRatio,
+            'spaceRatio' => $this->spaceRatio,
         ];
     }
 
diff --git a/src/Token/Tokenizer.php b/src/Token/Tokenizer.php
index c429148..f105a34 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/src/Util/StringUtil.php b/src/Util/StringUtil.php
index f9465ab..7257f63 100644
--- a/src/Util/StringUtil.php
+++ b/src/Util/StringUtil.php
@@ -8,9 +8,20 @@ use Symfony\Component\String\UnicodeString;
 
 class StringUtil
 {
+    /**
+     * @see UnicodeString::camel()
+     * @see UnicodeString::snake()
+     */
     public static function toSnakeCase(string $string): string
     {
-        return (new UnicodeString($string))->snake()->toString();
+        return (new UnicodeString($string))
+            ->replaceMatches('/[^\pL_0-9]++/u', '_')
+            ->replaceMatches('/(\p{Lu}+)(\p{Lu}\p{Ll})/u', '\1_\2')
+            ->replaceMatches('/([\p{Ll}0-9])(\p{Lu})/u', '\1_\2')
+            ->replaceMatches('/_+/', '_')
+            ->trim('_')
+            ->lower()
+            ->toString();
     }
 
     public static function toCamelCase(string $string): string
@@ -25,6 +36,6 @@ class StringUtil
 
     public static function toKebabCase(string $string): string
     {
-        return (new UnicodeString($string))->snake()->replace('_', '-')->toString();
+        return str_replace('_', '-', self::toSnakeCase($string));
     }
 }


Test patch:
diff --git a/tests/Rules/File/DirectoryName/DirectoryNameRuleTest.php b/tests/Rules/File/DirectoryName/DirectoryNameRuleTest.php
index 7709883..7ed78d7 100644
--- a/tests/Rules/File/DirectoryName/DirectoryNameRuleTest.php
+++ b/tests/Rules/File/DirectoryName/DirectoryNameRuleTest.php
@@ -80,6 +80,14 @@ final class DirectoryNameRuleTest extends AbstractRuleTestCase
             ],
             __DIR__.'/templates/_directory_name_rule_test/DirectoryNameRuleTest.twig'
         );
+
+        $this->checkRule(
+            new DirectoryNameRule(baseDirectory: __DIR__.'/templates', optionalPrefix: '_'),
+            [
+                'DirectoryName.Error' => 'The directory name must use snake_case; expected _directory_name_rule_test.',
+            ],
+            __DIR__.'/templates/_directoryNameRuleTest/DirectoryNameRuleTest.twig'
+        );
     }
 
     public function testRulePascalCase(): void
diff --git a/tests/Rules/File/DirectoryName/templates/_directoryNameRuleTest/DirectoryNameRuleTest.twig b/tests/Rules/File/DirectoryName/templates/_directoryNameRuleTest/DirectoryNameRuleTest.twig
new file mode 100644
index 0000000..4a584e4
--- /dev/null
+++ b/tests/Rules/File/DirectoryName/templates/_directoryNameRuleTest/DirectoryNameRuleTest.twig
@@ -0,0 +1,1 @@
+Nothing
diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig b/tests/Rules/String/HashQuote/HashQuoteRuleTest.fixed.twig
new file mode 100644
index 0000000..0955475
--- /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 0000000..809333c
--- /dev/null
+++ b/tests/Rules/String/HashQuote/HashQuoteRuleTest.php
@@ -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');
+    }
+}
diff --git a/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig b/tests/Rules/String/HashQuote/HashQuoteRuleTest.twig
new file mode 100644
index 0000000..3fd48af
--- /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 0000000..bd9ff2c
--- /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} %}
diff --git a/tests/Rules/String/SimpleQuote/SingleQuoteRuleTest.php b/tests/Rules/String/SimpleQuote/SingleQuoteRuleTest.php
index dc954e1..15395fb 100644
--- a/tests/Rules/String/SimpleQuote/SingleQuoteRuleTest.php
+++ b/tests/Rules/String/SimpleQuote/SingleQuoteRuleTest.php
@@ -12,11 +12,11 @@ class SingleQuoteRuleTest extends AbstractRuleTestCase
     public function testConfiguration(): void
     {
         static::assertSame(
-            ['skip_string_containing_single_quote' => true],
+            ['skipStringContainingSingleQuote' => true],
             (new SingleQuoteRule())->getConfiguration()
         );
         static::assertSame(
-            ['skip_string_containing_single_quote' => false],
+            ['skipStringContainingSingleQuote' => false],
             (new SingleQuoteRule(false))->getConfiguration()
         );
     }
diff --git a/tests/Rules/Variable/VariableName/VariableNameRuleTest.php b/tests/Rules/Variable/VariableName/VariableNameRuleTest.php
index 47460fd..a91cffc 100644
--- a/tests/Rules/Variable/VariableName/VariableNameRuleTest.php
+++ b/tests/Rules/Variable/VariableName/VariableNameRuleTest.php
@@ -12,13 +12,19 @@ final class VariableNameRuleTest extends AbstractRuleTestCase
     public function testConfiguration(): void
     {
         static::assertSame(
-            ['case' => VariableNameRule::SNAKE_CASE],
+            [
+                'case' => VariableNameRule::SNAKE_CASE,
+                'optionalPrefix' => '',
+            ],
             (new VariableNameRule())->getConfiguration()
         );
 
         static::assertSame(
-            ['case' => VariableNameRule::PASCAL_CASE],
-            (new VariableNameRule(VariableNameRule::PASCAL_CASE))->getConfiguration()
+            [
+                'case' => VariableNameRule::PASCAL_CASE,
+                'optionalPrefix' => 'foo',
+            ],
+            (new VariableNameRule(VariableNameRule::PASCAL_CASE, 'foo'))->getConfiguration()
         );
     }
 
@@ -31,6 +37,8 @@ final class VariableNameRuleTest extends AbstractRuleTestCase
             'VariableName.Error:7:8' => 'The var name must use snake_case; expected key_foo.',
             'VariableName.Error:7:16' => 'The var name must use snake_case; expected user_foo.',
             'VariableName.Error:9:8' => 'The var name must use snake_case; expected foo_bar.',
+            'VariableName.Error:11:8' => 'The var name must use snake_case; expected foo_bar.',
+            'VariableName.Error:12:8' => 'The var name must use snake_case; expected foo_bar.',
         ]);
     }
 
@@ -40,6 +48,8 @@ final class VariableNameRuleTest extends AbstractRuleTestCase
             'VariableName.Error:3:8' => 'The var name must use camelCase; expected fooBar.',
             'VariableName.Error:4:8' => 'The var name must use camelCase; expected fooBar.',
             'VariableName.Error:9:8' => 'The var name must use camelCase; expected fooBar.',
+            'VariableName.Error:11:8' => 'The var name must use camelCase; expected fooBar.',
+            'VariableName.Error:12:8' => 'The var name must use camelCase; expected fooBar.',
         ]);
     }
 
@@ -52,6 +62,21 @@ final class VariableNameRuleTest extends AbstractRuleTestCase
             'VariableName.Error:6:8' => 'The var name must use PascalCase; expected UserFoo.',
             'VariableName.Error:7:8' => 'The var name must use PascalCase; expected KeyFoo.',
             'VariableName.Error:7:16' => 'The var name must use PascalCase; expected UserFoo.',
+            'VariableName.Error:11:8' => 'The var name must use PascalCase; expected FooBar.',
+            'VariableName.Error:12:8' => 'The var name must use PascalCase; expected FooBar.',
+        ]);
+    }
+
+    public function testRuleWithPrefix(): void
+    {
+        $this->checkRule(new VariableNameRule(optionalPrefix: '_'), [
+            'VariableName.Error:2:8' => 'The var name must use snake_case; expected foo_bar.',
+            'VariableName.Error:4:8' => 'The var name must use snake_case; expected foo_bar.',
+            'VariableName.Error:6:8' => 'The var name must use snake_case; expected user_foo.',
+            'VariableName.Error:7:8' => 'The var name must use snake_case; expected key_foo.',
+            'VariableName.Error:7:16' => 'The var name must use snake_case; expected user_foo.',
+            'VariableName.Error:9:8' => 'The var name must use snake_case; expected foo_bar.',
+            'VariableName.Error:12:8' => 'The var name must use snake_case; expected _foo_bar.',
         ]);
     }
 }
diff --git a/tests/Rules/Variable/VariableName/VariableNameRuleTest.twig b/tests/Rules/Variable/VariableName/VariableNameRuleTest.twig
index 031e4f4..c837557 100644
--- a/tests/Rules/Variable/VariableName/VariableNameRuleTest.twig
+++ b/tests/Rules/Variable/VariableName/VariableNameRuleTest.twig
@@ -7,3 +7,6 @@
 {% for keyFoo, userFoo in users %}{% endfor %}
 
 {% set FOOBar = 'foo' %}
+
+{% set _foo_bar = 'foo' %}
+{% set _fooBar = 'foo' %}
diff --git a/tests/Rules/Whitespace/Indent/IndentRuleTest.php b/tests/Rules/Whitespace/Indent/IndentRuleTest.php
index 405f898..ac6fdf3 100644
--- a/tests/Rules/Whitespace/Indent/IndentRuleTest.php
+++ b/tests/Rules/Whitespace/Indent/IndentRuleTest.php
@@ -11,8 +11,8 @@ final class IndentRuleTest extends AbstractRuleTestCase
 {
     public function testConfiguration(): void
     {
-        static::assertSame(['space_ratio' => 4], (new IndentRule())->getConfiguration());
-        static::assertSame(['space_ratio' => 2], (new IndentRule(2))->getConfiguration());
+        static::assertSame(['spaceRatio' => 4], (new IndentRule())->getConfiguration());
+        static::assertSame(['spaceRatio' => 2], (new IndentRule(2))->getConfiguration());
     }
 
     public function testRule(): void
diff --git a/tests/Util/StringUtilTest.php b/tests/Util/StringUtilTest.php
index 35c0c02..9b5b3e6 100644
--- a/tests/Util/StringUtilTest.php
+++ b/tests/Util/StringUtilTest.php
@@ -28,6 +28,10 @@ class StringUtilTest extends TestCase
         yield ['foo-bar', 'foo_bar'];
         yield ['FooBar', 'foo_bar'];
         yield ['FOOBar', 'foo_bar'];
+        yield ['foo_bar1', 'foo_bar1'];
+        yield ['foo_bar_1', 'foo_bar_1'];
+        yield ['foo__bar', 'foo_bar'];
+        yield ['_foo_bar_', 'foo_bar'];
     }
 
     /**
@@ -49,6 +53,10 @@ class StringUtilTest extends TestCase
         yield ['foo-bar', 'fooBar'];
         yield ['FooBar', 'fooBar'];
         yield ['FOOBar', 'fooBar'];
+        yield ['foo_bar1', 'fooBar1'];
+        yield ['foo_bar_1', 'fooBar1'];
+        yield ['foo__bar', 'fooBar'];
+        yield ['_foo_bar_', 'fooBar'];
     }
 
     /**
@@ -70,6 +78,10 @@ class StringUtilTest extends TestCase
         yield ['foo-bar', 'FooBar'];
         yield ['FooBar', 'FooBar'];
         yield ['FOOBar', 'FOOBar'];
+        yield ['foo_bar1', 'FooBar1'];
+        yield ['foo_bar_1', 'FooBar1'];
+        yield ['foo__bar', 'FooBar'];
+        yield ['_foo_bar_', 'FooBar'];
     }
 
     /**
@@ -91,5 +103,9 @@ class StringUtilTest extends TestCase
         yield ['foo-bar', 'foo-bar'];
         yield ['FooBar', 'foo-bar'];
         yield ['FOOBar', 'foo-bar'];
+        yield ['foo_bar1', 'foo-bar1'];
+        yield ['foo_bar_1', 'foo-bar-1'];
+        yield ['foo__bar', 'foo-bar'];
+        yield ['_foo_bar_', 'foo-bar'];
     }
 }