Browse Source

AssignNullCoalescingToCoalesceEqualFixer - introduction

SpacePossum 5 years ago
parent
commit
4968ead667

+ 1 - 0
doc/ruleSets/PHP74Migration.rst

@@ -8,5 +8,6 @@ Rules
 -----
 
 - `@PHP73Migration <./PHP73Migration.rst>`_
+- `assign_null_coalescing_to_coalesce_equal <./../rules/operator/assign_null_coalescing_to_coalesce_equal.rst>`_
 - `normalize_index_brace <./../rules/array_notation/normalize_index_brace.rst>`_
 - `short_scalar_cast <./../rules/cast_notation/short_scalar_cast.rst>`_

+ 2 - 0
doc/rules/index.rst

@@ -341,6 +341,8 @@ Naming
 Operator
 --------
 
+- `assign_null_coalescing_to_coalesce_equal <./operator/assign_null_coalescing_to_coalesce_equal.rst>`_
+    Use the null coalescing assignment operator ``??=`` where possible.
 - `binary_operator_spaces <./operator/binary_operator_spaces.rst>`_
     Binary operators should be surrounded by space as configured.
 - `concat_space <./operator/concat_space.rst>`_

+ 33 - 0
doc/rules/operator/assign_null_coalescing_to_coalesce_equal.rst

@@ -0,0 +1,33 @@
+=================================================
+Rule ``assign_null_coalescing_to_coalesce_equal``
+=================================================
+
+Use the null coalescing assignment operator ``??=`` where possible.
+
+Examples
+--------
+
+Example #1
+~~~~~~~~~~
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+   -$foo = $foo ?? 1;
+   +$foo ??= 1;
+
+Rule sets
+---------
+
+The rule is part of the following rule sets:
+
+@PHP74Migration
+  Using the `@PHP74Migration <./../../ruleSets/PHP74Migration.rst>`_ rule set will enable the ``assign_null_coalescing_to_coalesce_equal`` rule.
+
+@PHP80Migration
+  Using the `@PHP80Migration <./../../ruleSets/PHP80Migration.rst>`_ rule set will enable the ``assign_null_coalescing_to_coalesce_equal`` rule.
+
+@PHP81Migration
+  Using the `@PHP81Migration <./../../ruleSets/PHP81Migration.rst>`_ rule set will enable the ``assign_null_coalescing_to_coalesce_equal`` rule.

+ 221 - 0
src/Fixer/Operator/AssignNullCoalescingToCoalesceEqualFixer.php

@@ -0,0 +1,221 @@
+<?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\Operator;
+
+use PhpCsFixer\AbstractFixer;
+use PhpCsFixer\FixerDefinition\FixerDefinition;
+use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
+use PhpCsFixer\FixerDefinition\VersionSpecification;
+use PhpCsFixer\FixerDefinition\VersionSpecificCodeSample;
+use PhpCsFixer\Tokenizer\CT;
+use PhpCsFixer\Tokenizer\Token;
+use PhpCsFixer\Tokenizer\Tokens;
+
+final class AssignNullCoalescingToCoalesceEqualFixer extends AbstractFixer
+{
+    /**
+     * {@inheritdoc}
+     */
+    public function getDefinition(): FixerDefinitionInterface
+    {
+        return new FixerDefinition(
+            'Use the null coalescing assignment operator `??=` where possible.',
+            [
+                new VersionSpecificCodeSample(
+                    "<?php\n\$foo = \$foo ?? 1;\n",
+                    new VersionSpecification(70400)
+                ),
+            ]
+        );
+    }
+
+    /**
+     * {@inheritdoc}
+     *
+     * Must run before BinaryOperatorSpacesFixer, NoWhitespaceInBlankLineFixer.
+     * Must run after TernaryToNullCoalescingFixer.
+     */
+    public function getPriority(): int
+    {
+        return -1;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function isCandidate(Tokens $tokens): bool
+    {
+        return \defined('T_COALESCE_EQUAL') && $tokens->isTokenKindFound(T_COALESCE);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
+    {
+        for ($index = \count($tokens) - 1; $index > 3; --$index) {
+            if (!$tokens[$index]->isGivenKind(T_COALESCE)) {
+                continue;
+            }
+
+            // make sure after '??' does not contain '? :'
+
+            $nextIndex = $tokens->getNextTokenOfKind($index, ['?', ';', [T_CLOSE_TAG]]);
+
+            if ($tokens[$nextIndex]->equals('?')) {
+                continue;
+            }
+
+            // get what is before '??'
+
+            $beforeRange = $this->getBeforeOperator($tokens, $index);
+            $equalsIndex = $tokens->getPrevMeaningfulToken($beforeRange['start']);
+
+            // make sure that before that is '='
+
+            if (!$tokens[$equalsIndex]->equals('=')) {
+                continue;
+            }
+
+            // get what is before '='
+
+            $assignRange = $this->getBeforeOperator($tokens, $equalsIndex);
+            $beforeAssignmentIndex = $tokens->getPrevMeaningfulToken($assignRange['start']);
+
+            // make sure that before that is ';', '{', '}', '(', ')' or '<php'
+
+            if (!$tokens[$beforeAssignmentIndex]->equalsAny([';', '{', '}', ')', '(', [T_OPEN_TAG]])) {
+                continue;
+            }
+
+            // make sure before and after are the same
+
+            if (!$this->rangeEqualsRange($tokens, $assignRange, $beforeRange)) {
+                continue;
+            }
+
+            $tokens[$equalsIndex] = new Token([T_COALESCE_EQUAL, '??=']);
+            $tokens->clearTokenAndMergeSurroundingWhitespace($index);
+            $this->clearMeaningfulFromRange($tokens, $beforeRange);
+
+            foreach ([$equalsIndex, $assignRange['end']] as $i) {
+                $i = $tokens->getNonEmptySibling($i, 1);
+
+                if ($tokens[$i]->isWhitespace(" \t")) {
+                    $tokens[$i] = new Token([T_WHITESPACE, ' ']);
+                } elseif (!$tokens[$i]->isWhitespace()) {
+                    $tokens->insertAt($i, new Token([T_WHITESPACE, ' ']));
+                }
+            }
+        }
+    }
+
+    private function getBeforeOperator(Tokens $tokens, int $index): array
+    {
+        $controlStructureWithoutBracesTypes = [T_IF, T_ELSE, T_ELSEIF, T_FOR, T_FOREACH, T_WHILE];
+
+        $index = $tokens->getPrevMeaningfulToken($index);
+        $range = [
+            'start' => $index,
+            'end' => $index,
+        ];
+
+        $previousIndex = $index;
+        $previousToken = $tokens[$previousIndex];
+
+        while ($previousToken->equalsAny([
+            '$',
+            ']',
+            ')',
+            [CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE],
+            [CT::T_DYNAMIC_PROP_BRACE_CLOSE],
+            [CT::T_DYNAMIC_VAR_BRACE_CLOSE],
+            [T_NS_SEPARATOR],
+            [T_STRING],
+            [T_VARIABLE],
+        ])) {
+            $blockType = Tokens::detectBlockType($previousToken);
+
+            if (null !== $blockType) {
+                $blockStart = $tokens->findBlockStart($blockType['type'], $previousIndex);
+
+                if ($tokens[$previousIndex]->equals(')') && $tokens[$tokens->getPrevMeaningfulToken($blockStart)]->isGivenKind($controlStructureWithoutBracesTypes)) {
+                    break; // we went too far back
+                }
+
+                $previousIndex = $blockStart;
+            }
+
+            $index = $previousIndex;
+            $previousIndex = $tokens->getPrevMeaningfulToken($previousIndex);
+            $previousToken = $tokens[$previousIndex];
+        }
+
+        if ($previousToken->isGivenKind(T_OBJECT_OPERATOR)) {
+            $index = $this->getBeforeOperator($tokens, $previousIndex)['start'];
+        } elseif ($previousToken->isGivenKind(T_PAAMAYIM_NEKUDOTAYIM)) {
+            $index = $this->getBeforeOperator($tokens, $tokens->getPrevMeaningfulToken($previousIndex))['start'];
+        }
+
+        $range['start'] = $index;
+
+        return $range;
+    }
+
+    /**
+     * Meaningful compare of tokens within ranges.
+     */
+    private function rangeEqualsRange(Tokens $tokens, array $range1, array $range2): bool
+    {
+        $leftStart = $range1['start'];
+        $leftEnd = $range1['end'];
+
+        while ($tokens[$leftStart]->equals('(') && $tokens[$leftEnd]->equals(')')) {
+            $leftStart = $tokens->getNextMeaningfulToken($leftStart);
+            $leftEnd = $tokens->getPrevMeaningfulToken($leftEnd);
+        }
+
+        $rightStart = $range2['start'];
+        $rightEnd = $range2['end'];
+
+        while ($tokens[$rightStart]->equals('(') && $tokens[$rightEnd]->equals(')')) {
+            $rightStart = $tokens->getNextMeaningfulToken($rightStart);
+            $rightEnd = $tokens->getPrevMeaningfulToken($rightEnd);
+        }
+
+        while ($leftStart <= $leftEnd && $rightStart <= $rightEnd) {
+            if (
+                !$tokens[$leftStart]->equals($tokens[$rightStart])
+                && !($tokens[$leftStart]->equalsAny(['[', [CT::T_ARRAY_INDEX_CURLY_BRACE_OPEN]]) && $tokens[$rightStart]->equalsAny(['[', [CT::T_ARRAY_INDEX_CURLY_BRACE_OPEN]]))
+                && !($tokens[$leftStart]->equalsAny([']', [CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE]]) && $tokens[$rightStart]->equalsAny([']', [CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE]]))
+            ) {
+                return false;
+            }
+
+            $leftStart = $tokens->getNextMeaningfulToken($leftStart);
+            $rightStart = $tokens->getNextMeaningfulToken($rightStart);
+        }
+
+        return $leftStart > $leftEnd && $rightStart > $rightEnd;
+    }
+
+    private function clearMeaningfulFromRange(Tokens $tokens, array $range): void
+    {
+        // $range['end'] must be meaningful!
+        for ($i = $range['end']; $i >= $range['start']; $i = $tokens->getPrevMeaningfulToken($i)) {
+            $tokens->clearTokenAndMergeSurroundingWhitespace($i);
+        }
+    }
+}

+ 1 - 1
src/Fixer/Operator/BinaryOperatorSpacesFixer.php

@@ -256,7 +256,7 @@ $array = [
     /**
      * {@inheritdoc}
      *
-     * Must run after ArrayIndentationFixer, ArraySyntaxFixer, ListSyntaxFixer, ModernizeStrposFixer, NoMultilineWhitespaceAroundDoubleArrowFixer, NoUnsetCastFixer, PowToExponentiationFixer, StandardizeNotEqualsFixer, StrictComparisonFixer.
+     * Must run after ArrayIndentationFixer, ArraySyntaxFixer, AssignNullCoalescingToCoalesceEqualFixer, ListSyntaxFixer, ModernizeStrposFixer, NoMultilineWhitespaceAroundDoubleArrowFixer, NoUnsetCastFixer, PowToExponentiationFixer, StandardizeNotEqualsFixer, StrictComparisonFixer.
      */
     public function getPriority(): int
     {

+ 10 - 0
src/Fixer/Operator/TernaryToNullCoalescingFixer.php

@@ -41,6 +41,16 @@ final class TernaryToNullCoalescingFixer extends AbstractFixer
         );
     }
 
+    /**
+     * {@inheritdoc}
+     *
+     * Must run before AssignNullCoalescingToCoalesceEqualFixer.
+     */
+    public function getPriority(): int
+    {
+        return 0;
+    }
+
     /**
      * {@inheritdoc}
      */

+ 1 - 1
src/Fixer/Whitespace/NoWhitespaceInBlankLineFixer.php

@@ -42,7 +42,7 @@ final class NoWhitespaceInBlankLineFixer extends AbstractFixer implements Whites
     /**
      * {@inheritdoc}
      *
-     * Must run after CombineConsecutiveIssetsFixer, CombineConsecutiveUnsetsFixer, FunctionToConstantFixer, NoEmptyCommentFixer, NoEmptyPhpdocFixer, NoEmptyStatementFixer, NoUselessElseFixer, NoUselessReturnFixer.
+     * Must run after AssignNullCoalescingToCoalesceEqualFixer, CombineConsecutiveIssetsFixer, CombineConsecutiveUnsetsFixer, FunctionToConstantFixer, NoEmptyCommentFixer, NoEmptyPhpdocFixer, NoEmptyStatementFixer, NoUselessElseFixer, NoUselessReturnFixer.
      */
     public function getPriority(): int
     {

+ 1 - 0
src/RuleSet/Sets/PHP74MigrationSet.php

@@ -25,6 +25,7 @@ final class PHP74MigrationSet extends AbstractMigrationSetDescription
     {
         return [
             '@PHP73Migration' => true,
+            'assign_null_coalescing_to_coalesce_equal' => true,
             'normalize_index_brace' => true,
             'short_scalar_cast' => true,
         ];

+ 3 - 0
tests/AutoReview/FixerFactoryTest.php

@@ -72,6 +72,8 @@ final class FixerFactoryTest extends TestCase
             [$fixers['array_indentation'], $fixers['binary_operator_spaces']],
             [$fixers['array_syntax'], $fixers['binary_operator_spaces']],
             [$fixers['array_syntax'], $fixers['ternary_operator_spaces']],
+            [$fixers['assign_null_coalescing_to_coalesce_equal'], $fixers['binary_operator_spaces']],
+            [$fixers['assign_null_coalescing_to_coalesce_equal'], $fixers['no_whitespace_in_blank_line']],
             [$fixers['backtick_to_shell_exec'], $fixers['escape_implicit_backslashes']],
             [$fixers['backtick_to_shell_exec'], $fixers['explicit_string_variable']],
             [$fixers['backtick_to_shell_exec'], $fixers['native_function_invocation']],
@@ -293,6 +295,7 @@ final class FixerFactoryTest extends TestCase
             [$fixers['strict_param'], $fixers['native_function_invocation']],
             [$fixers['ternary_to_elvis_operator'], $fixers['no_trailing_whitespace']],
             [$fixers['ternary_to_elvis_operator'], $fixers['ternary_operator_spaces']],
+            [$fixers['ternary_to_null_coalescing'], $fixers['assign_null_coalescing_to_coalesce_equal']],
             [$fixers['unary_operator_spaces'], $fixers['not_operator_with_space']],
             [$fixers['unary_operator_spaces'], $fixers['not_operator_with_successor_space']],
             [$fixers['void_return'], $fixers['phpdoc_no_empty_return']],

+ 211 - 0
tests/Fixer/Operator/AssignNullCoalescingToCoalesceEqualFixerTest.php

@@ -0,0 +1,211 @@
+<?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\Operator;
+
+use PhpCsFixer\Tests\Test\AbstractFixerTestCase;
+
+/**
+ * @requires PHP 7.4
+ *
+ * @internal
+ *
+ * @covers \PhpCsFixer\Fixer\Operator\AssignNullCoalescingToCoalesceEqualFixer
+ */
+final class AssignNullCoalescingToCoalesceEqualFixerTest extends AbstractFixerTestCase
+{
+    /**
+     * @dataProvider provideFix74Cases
+     */
+    public function testFix74(string $expected, ?string $input = null): void
+    {
+        $this->doTest($expected, $input);
+    }
+
+    public function provideFix74Cases(): \Generator
+    {
+        yield 'simple' => [
+            '<?php $a ??= 1;',
+            '<?php $a = $a ?? 1;',
+        ];
+
+        yield 'minimal' => [
+            '<?php $a ??= 1;',
+            '<?php $a=$a??1;',
+        ];
+
+        yield 'simple array' => [
+            '<?php $a[1] ??= 1;',
+            '<?php $a[1] = $a[1] ?? 1;',
+        ];
+
+        yield 'simple array [0]' => [
+            '<?php $a[1][0] ??= 1;',
+            '<?php $a[1][0] = $a[1][0] ?? 1;',
+        ];
+
+        yield 'simple array ([0])' => [
+            '<?php $a[1][0] ??= 1;',
+            '<?php $a[1][0] = ($a[1][0]) ?? 1;',
+        ];
+
+        yield 'simple array, comment' => [
+            '<?php $a[1] /* 1 */ ??= /* 2 */ /* 3 */ /* 4 */ /* 5 */ 1;',
+            '<?php $a[1]/* 1 */ = /* 2 */ $a[1/* 3 */] /* 4 */ ??/* 5 */ 1;',
+        ];
+
+        yield [
+            '<?php \A\B::$foo ??= 1;',
+            '<?php \A\B::$foo = \A\B::$foo ?? 1;',
+        ];
+
+        yield 'same' => [
+            '<?php $a ??= 1;',
+            '<?php $a = ($a) ?? 1;',
+        ];
+
+        yield 'object' => [
+            '<?php $a->b ??= 1;',
+            '<?php $a->b = $a->b ?? 1;',
+        ];
+
+        yield 'object II' => [
+            '<?php $a->b[0]->{1} ??= 1;',
+            '<?php $a->b[0]->{1} = $a->b[0]->{1} ?? 1;',
+        ];
+
+        yield 'simple, before ;' => [
+            '<?php ; $a ??= 1;',
+            '<?php ; $a = $a ?? 1;',
+        ];
+
+        yield 'simple, before {' => [
+            '<?php { $a ??= 1; }',
+            '<?php { $a = $a ?? 1; }',
+        ];
+
+        yield 'simple, before }' => [
+            '<?php if ($a){} $a ??= 1;',
+            '<?php if ($a){} $a = $a ?? 1;',
+        ];
+
+        yield 'in call' => [
+            '<?php foo($a ??= 1);',
+            '<?php foo($a = $a ?? 1);',
+        ];
+
+        yield 'in call followed by end tag and ternary' => [
+            '<?php foo( $a ??= 1 ) ?><?php $b = $b ? $c : $d ?>',
+            '<?php foo( $a = $a ?? 1 ) ?><?php $b = $b ? $c : $d ?>',
+        ];
+
+        yield 'simple, before ) I' => [
+            '<?php if ($a) $a ??= 1;',
+            '<?php if ($a) $a = $a ?? 1;',
+        ];
+
+        yield 'simple, before ) II' => [
+            '<?php
+                if ($a) $a ??= 1;
+                foreach ($d as $i) $a ??= 1;
+                while (foo()) $a ??= 1;
+            ',
+            '<?php
+                if ($a) $a = $a ?? 1;
+                foreach ($d as $i) $a = $a ?? 1;
+                while (foo()) $a = $a ?? 1;
+            ',
+        ];
+
+        yield 'simple, end' => [
+            '<?php $a ??= 1 ?>',
+            '<?php $a = $a ?? 1 ?>',
+        ];
+
+        yield 'simple, 10x' => [
+            '<?php'.str_repeat(' $a ??= 1;', 10),
+            '<?php'.str_repeat(' $a = $a ?? 1;', 10),
+        ];
+
+        yield 'simple, multi line' => [
+            '<?php
+            $a
+             ??=
+              '.'
+               '.'
+                1;',
+            '<?php
+            $a
+             =
+              $a
+               ??
+                1;',
+        ];
+
+        yield 'dynamic var' => [
+            '<?php ${beers::$ale} ??= 1;',
+            '<?php ${beers::$ale} = ${beers::$ale} ?? 1;',
+        ];
+
+        yield [
+            '<?php $argv ??= $_SERVER[\'argv\'] ?? [];',
+            '<?php $argv = $argv ?? $_SERVER[\'argv\'] ?? [];',
+        ];
+
+        yield 'do not fix' => [
+            '<?php
+                $a = 1 + $a ?? $b;
+                $b + $a = $a ?? 1;
+                $b = $a ?? 1;
+                $b = $a ?? $b;
+                $d = $a + $c ; $c ?? $c;
+                $a = ($a ?? $b) && $c; // just to be sure
+                $a = (string) $a ?? 1;
+            ',
+        ];
+
+        yield 'do not fix because of precedence 1' => [
+            '<?php $a = $a ?? $b ? $c : $d;',
+        ];
+
+        yield 'do not fix because of precedence 2' => [
+            '<?php $a = $a ?? $b ? $c : $d ?>',
+        ];
+
+        if (\PHP_VERSION_ID < 80000) {
+            yield 'mixed array' => [
+                '<?php
+                $a[1] ??= 1;
+                $a{2} ??= 1;
+                $a{2}[$f] ??= 1;
+            ',
+                '<?php
+                $a[1] = $a[1] ?? 1;
+                $a{2} = $a{2} ?? 1;
+                $a{2}[$f] = $a{2}[$f] ?? 1;
+            ',
+            ];
+
+            yield 'same II' => [
+                '<?php $a[1] ??= 1;',
+                '<?php $a[1] = $a{1} ?? 1;',
+            ];
+
+            yield 'same III' => [
+                '<?php $a[1] ??= 1;',
+                '<?php $a[1] = (($a{1})) ?? 1;',
+            ];
+        }
+    }
+}

Some files were not shown because too many files changed in this diff