Browse Source

feat: Introduce `NumericLiteralSeparatorFixer` (#6761)

Co-authored-by: Greg Korba <greg@codito.dev>
Marvin Heilemann 1 year ago
parent
commit
28e40bcbb3

+ 86 - 0
doc/rules/basic/numeric_literal_separator.rst

@@ -0,0 +1,86 @@
+==================================
+Rule ``numeric_literal_separator``
+==================================
+
+Adds separators to numeric literals of any kind.
+
+Configuration
+-------------
+
+``override_existing``
+~~~~~~~~~~~~~~~~~~~~~
+
+Whether literals already containing underscores should be reformatted.
+
+Allowed types: ``bool``
+
+Default value: ``false``
+
+``strategy``
+~~~~~~~~~~~~
+
+Whether numeric literal should be separated by underscores or not.
+
+Allowed values: ``'no_separator'`` and ``'use_separator'``
+
+Default value: ``'no_separator'``
+
+Examples
+--------
+
+Example #1
+~~~~~~~~~~
+
+*Default* configuration.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+   -$integer = 1234_5678;
+   -$octal = 01_234_56;
+   -$binary = 0b00_10_01_00;
+   -$hexadecimal = 0x3D45_8F4F;
+   +$integer = 12345678;
+   +$octal = 0123456;
+   +$binary = 0b00100100;
+   +$hexadecimal = 0x3D458F4F;
+
+Example #2
+~~~~~~~~~~
+
+With configuration: ``['strategy' => 'use_separator']``.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+   -$integer = 12345678;
+   -$octal = 0123456;
+   -$binary = 0b0010010011011010;
+   -$hexadecimal = 0x3D458F4F;
+   +$integer = 12_345_678;
+   +$octal = 0123_456;
+   +$binary = 0b00100100_11011010;
+   +$hexadecimal = 0x3D_45_8F_4F;
+
+Example #3
+~~~~~~~~~~
+
+With configuration: ``['override_existing' => true]``.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+   -<?php $var = 24_40_21;
+   +<?php $var = 244021;
+References
+----------
+
+- Fixer class: `PhpCsFixer\\Fixer\\Basic\\NumericLiteralSeparatorFixer <./../../../src/Fixer/Basic/NumericLiteralSeparatorFixer.php>`_
+- Test class: `PhpCsFixer\\Tests\\Fixer\\Basic\\NumericLiteralSeparatorFixerTest <./../../../tests/Fixer/Basic/NumericLiteralSeparatorFixerTest.php>`_
+
+The test class defines officially supported behaviour. Each test case is a part of our backward compatibility promise.

+ 3 - 0
doc/rules/index.rst

@@ -101,6 +101,9 @@ Basic
 - `non_printable_character <./basic/non_printable_character.rst>`_ *(risky)*
 
   Remove Zero-width space (ZWSP), Non-breaking space (NBSP) and other invisible unicode symbols.
+- `numeric_literal_separator <./basic/numeric_literal_separator.rst>`_
+
+  Adds separators to numeric literals of any kind.
 - `octal_notation <./basic/octal_notation.rst>`_
 
   Literal octal must be in ``0o`` notation.

+ 207 - 0
src/Fixer/Basic/NumericLiteralSeparatorFixer.php

@@ -0,0 +1,207 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of PHP CS Fixer.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace PhpCsFixer\Fixer\Basic;
+
+use PhpCsFixer\AbstractFixer;
+use PhpCsFixer\Fixer\ConfigurableFixerInterface;
+use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
+use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
+use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
+use PhpCsFixer\FixerDefinition\CodeSample;
+use PhpCsFixer\FixerDefinition\FixerDefinition;
+use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
+use PhpCsFixer\Preg;
+use PhpCsFixer\Tokenizer\Token;
+use PhpCsFixer\Tokenizer\Tokens;
+
+/**
+ * Let's you add underscores to numeric literals.
+ *
+ * Inspired by:
+ * - {@link https://github.com/kubawerlos/php-cs-fixer-custom-fixers/blob/main/src/Fixer/NumericLiteralSeparatorFixer.php}
+ * - {@link https://github.com/sindresorhus/eslint-plugin-unicorn/blob/main/rules/numeric-separators-style.js}
+ *
+ * @author Marvin Heilemann <marvin.heilemann+github@googlemail.com>
+ * @author Greg Korba <greg@codito.dev>
+ */
+final class NumericLiteralSeparatorFixer extends AbstractFixer implements ConfigurableFixerInterface
+{
+    public const STRATEGY_USE_SEPARATOR = 'use_separator';
+    public const STRATEGY_NO_SEPARATOR = 'no_separator';
+
+    public function getDefinition(): FixerDefinitionInterface
+    {
+        return new FixerDefinition(
+            'Adds separators to numeric literals of any kind.',
+            [
+                new CodeSample(
+                    <<<'PHP'
+                        <?php
+                        $integer = 1234_5678;
+                        $octal = 01_234_56;
+                        $binary = 0b00_10_01_00;
+                        $hexadecimal = 0x3D45_8F4F;
+
+                        PHP
+                ),
+                new CodeSample(
+                    <<<'PHP'
+                        <?php
+                        $integer = 12345678;
+                        $octal = 0123456;
+                        $binary = 0b0010010011011010;
+                        $hexadecimal = 0x3D458F4F;
+
+                        PHP
+                    ,
+                    ['strategy' => self::STRATEGY_USE_SEPARATOR],
+                ),
+                new CodeSample(
+                    "<?php \$var = 24_40_21;\n",
+                    ['override_existing' => true]
+                ),
+            ]
+        );
+    }
+
+    public function isCandidate(Tokens $tokens): bool
+    {
+        return $tokens->isAnyTokenKindsFound([T_DNUMBER, T_LNUMBER]);
+    }
+
+    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
+    {
+        return new FixerConfigurationResolver([
+            (new FixerOptionBuilder(
+                'override_existing',
+                'Whether literals already containing underscores should be reformatted.'
+            ))
+                ->setAllowedTypes(['bool'])
+                ->setDefault(false)
+                ->getOption(),
+            (new FixerOptionBuilder(
+                'strategy',
+                'Whether numeric literal should be separated by underscores or not.'
+            ))
+                ->setAllowedValues([self::STRATEGY_USE_SEPARATOR, self::STRATEGY_NO_SEPARATOR])
+                ->setDefault(self::STRATEGY_NO_SEPARATOR)
+                ->getOption(),
+        ]);
+    }
+
+    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
+    {
+        foreach ($tokens as $index => $token) {
+            if (!$token->isGivenKind([T_DNUMBER, T_LNUMBER])) {
+                continue;
+            }
+
+            $content = $token->getContent();
+
+            $newContent = $this->formatValue($content);
+
+            if ($content === $newContent) {
+                // Skip Token override if its the same content, like when it
+                // already got a valid literal separator structure.
+                continue;
+            }
+
+            $tokens[$index] = new Token([$token->getId(), $newContent]);
+        }
+    }
+
+    private function formatValue(string $value): string
+    {
+        if (self::STRATEGY_NO_SEPARATOR === $this->configuration['strategy']) {
+            return str_contains($value, '_') ? str_replace('_', '', $value) : $value;
+        }
+
+        if (true === $this->configuration['override_existing']) {
+            $value = str_replace('_', '', $value);
+        } elseif (str_contains($value, '_')) {
+            // Keep already underscored literals untouched.
+            return $value;
+        }
+
+        $lowerValue = strtolower($value);
+
+        if (str_starts_with($lowerValue, '0b')) {
+            // Binary
+            return $this->insertEveryRight($value, 8, 2);
+        }
+
+        if (str_starts_with($lowerValue, '0x')) {
+            // Hexadecimal
+            return $this->insertEveryRight($value, 2, 2);
+        }
+
+        if (str_starts_with($lowerValue, '0o')) {
+            // Octal
+            return $this->insertEveryRight($value, 3, 2);
+        }
+        if (str_starts_with($lowerValue, '0')) {
+            // Octal prior PHP 8.1
+            return $this->insertEveryRight($value, 3, 1);
+        }
+
+        // All other types
+
+        /** If its a negative value we need an offset */
+        $negativeOffset = static fn ($v) => str_contains($v, '-') ? 1 : 0;
+
+        Preg::matchAll('/([0-9-_]+)((\.)([0-9_]+))?((e)([0-9-_]+))?/i', $value, $result);
+
+        $integer = $result[1][0];
+        $joinedValue = $this->insertEveryRight($integer, 3, $negativeOffset($integer));
+
+        $dot = $result[3][0];
+        if ('' !== $dot) {
+            $integer = $result[4][0];
+            $decimal = $this->insertEveryLeft($integer, 3, $negativeOffset($integer));
+            $joinedValue = $joinedValue.$dot.$decimal;
+        }
+
+        $tim = $result[6][0];
+        if ('' !== $tim) {
+            $integer = $result[7][0];
+            $times = $this->insertEveryRight($integer, 3, $negativeOffset($integer));
+            $joinedValue = $joinedValue.$tim.$times;
+        }
+
+        return $joinedValue;
+    }
+
+    private function insertEveryRight(string $value, int $length, int $offset = 0): string
+    {
+        $position = $length * -1;
+        while ($position > -(\strlen($value) - $offset)) {
+            $value = substr_replace($value, '_', $position, 0);
+            $position -= $length + 1;
+        }
+
+        return $value;
+    }
+
+    private function insertEveryLeft(string $value, int $length, int $offset = 0): string
+    {
+        $position = $length;
+        while ($position < \strlen($value)) {
+            $value = substr_replace($value, '_', $position, $offset);
+            $position += $length + 1;
+        }
+
+        return $value;
+    }
+}

+ 184 - 0
tests/Fixer/Basic/NumericLiteralSeparatorFixerTest.php

@@ -0,0 +1,184 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * This file is part of PHP CS Fixer.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace PhpCsFixer\Tests\Fixer\Basic;
+
+use PhpCsFixer\Fixer\Basic\NumericLiteralSeparatorFixer;
+use PhpCsFixer\Tests\Test\AbstractFixerTestCase;
+
+/**
+ * @author Marvin Heilemann <marvin.heilemann+github@googlemail.com>
+ *
+ * @internal
+ *
+ * @covers \PhpCsFixer\Fixer\Basic\NumericLiteralSeparatorFixer
+ */
+final class NumericLiteralSeparatorFixerTest extends AbstractFixerTestCase
+{
+    /**
+     * @param array<string, mixed> $config
+     *
+     * @dataProvider provideFixCases
+     */
+    public function testFix(string $expected, ?string $input = null, ?array $config = []): void
+    {
+        $this->fixer->configure($config);
+        $this->doTest($expected, $input);
+    }
+
+    /**
+     * @return iterable<string, array{0: string, 1?: null|string, 2?: array<string, mixed>}>
+     */
+    public static function provideFixCases(): iterable
+    {
+        yield 'do not override existing separator' => [
+            <<<'PHP'
+                <?php
+                echo 0B01010100_01101000;
+                echo 70_10_00;
+
+                PHP,
+            null,
+            [
+                'override_existing' => false,
+                'strategy' => NumericLiteralSeparatorFixer::STRATEGY_USE_SEPARATOR,
+            ],
+        ];
+
+        yield 'override existing separator' => [
+            <<<'PHP'
+                <?php
+                echo 1_234.5;
+                echo 701_000;
+                PHP,
+            <<<'PHP'
+                <?php
+                echo 123_4.5;
+                echo 70_10_00;
+                PHP,
+            [
+                'override_existing' => true,
+                'strategy' => NumericLiteralSeparatorFixer::STRATEGY_USE_SEPARATOR,
+            ],
+        ];
+
+        yield from self::yieldCases([
+            'decimal' => [
+                '1234' => '1_234',
+                '-1234' => '-1_234',
+                '12345' => '12_345',
+                '123456' => '123_456',
+            ],
+            'binary' => [
+                '0b0101010001101000' => '0b01010100_01101000',
+                '0b01010100011010000110010101101111' => '0b01010100_01101000_01100101_01101111',
+                '0b110001000' => '0b1_10001000',
+            ],
+            'float' => [
+                '1234.5' => '1_234.5',
+                '1.2345' => '1.234_5',
+                '1234e5' => '1_234e5',
+                '1234E5' => '1_234E5',
+                '1e2345' => '1e2_345',
+                '1234.5678e1234' => '1_234.567_8e1_234',
+                '1.1e-1234' => '1.1e-1_234',
+                '1.1e-12345' => '1.1e-12_345',
+                '1.1e-123456' => '1.1e-123_456',
+            ],
+            'hexadecimal' => [
+                '0x42726F776E' => '0x42_72_6F_77_6E',
+                '0X42726F776E' => '0X42_72_6F_77_6E',
+                '0x2726F776E' => '0x2_72_6F_77_6E',
+                '0x1234567890abcdef' => '0x12_34_56_78_90_ab_cd_ef',
+                '0X1234567890ABCDEF' => '0X12_34_56_78_90_AB_CD_EF',
+                '0x1234e5' => '0x12_34_e5',
+            ],
+            'octal' => [
+                '012345' => '012_345',
+                '0123456' => '0123_456',
+                '01234567' => '01_234_567',
+            ],
+        ]);
+    }
+
+    /**
+     * @param array<string, mixed> $config
+     *
+     * @requires PHP 8.1
+     *
+     * @dataProvider provideFix81Cases
+     */
+    public function testFix81(string $expected, ?string $input = null, ?array $config = []): void
+    {
+        $this->fixer->configure($config);
+        $this->doTest($expected, $input);
+    }
+
+    /**
+     * @return iterable<string, array{0: string, 1?: null|string, 2?: array<string, mixed>}>
+     */
+    public static function provideFix81Cases(): iterable
+    {
+        yield 'do not override existing separator' => [
+            '<?php echo 0o123_45;',
+            null,
+            [
+                'override_existing' => false,
+                'strategy' => NumericLiteralSeparatorFixer::STRATEGY_USE_SEPARATOR,
+            ],
+        ];
+
+        yield 'override existing separator' => [
+            '<?php echo 1_234.5;',
+            '<?php echo 123_4.5;',
+            [
+                'override_existing' => true,
+                'strategy' => NumericLiteralSeparatorFixer::STRATEGY_USE_SEPARATOR,
+            ],
+        ];
+
+        yield from self::yieldCases([
+            'octal' => [
+                '0o12345' => '0o12_345',
+                '0o123456' => '0o123_456',
+            ],
+        ]);
+    }
+
+    /**
+     * @param array<string, array<mixed, mixed>> $cases
+     *
+     * @return iterable<string, array{0: string, 1?: null|string, 2?: array<string, mixed>}>
+     */
+    private static function yieldCases(array $cases): iterable
+    {
+        foreach ($cases as $pairsType => $pairs) {
+            foreach ($pairs as $withoutSeparator => $withSeparator) {
+                yield "add separator to {$pairsType} {$withoutSeparator}" => [
+                    sprintf('<?php echo %s;', $withSeparator),
+                    sprintf('<?php echo %s;', $withoutSeparator),
+                    ['strategy' => NumericLiteralSeparatorFixer::STRATEGY_USE_SEPARATOR],
+                ];
+            }
+
+            foreach ($pairs as $withoutSeparator => $withSeparator) {
+                yield "remove separator from {$pairsType} {$withoutSeparator}" => [
+                    sprintf('<?php echo %s;', $withoutSeparator),
+                    sprintf('<?php echo %s;', $withSeparator),
+                    ['strategy' => NumericLiteralSeparatorFixer::STRATEGY_NO_SEPARATOR],
+                ];
+            }
+        }
+    }
+}