Repository: FriendsOfPHP/PHP-CS-Fixer (php)

Issue:
Redundant concatenation fixer
Is there a fixer for removing redundant string concatenation, as in the following example?

```diff
-echo 'foo' . 'bar';
+echo 'foobar';
```

Pull request description:
feature: NoUselessConcatOperatorFixer - Introduction
Closes #4491.

Patch:
diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 34381e093..4d932edac 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -46,6 +46,7 @@
         'header_comment' => ['header' => $header],
         'heredoc_indentation' => false, // TODO switch on when # of PR's is lower
         'modernize_strpos' => true, // needs PHP 8+ or polyfill
+        'no_useless_concat_operator' => false, // TODO switch back on when the `src/Console/Application.php` no longer needs the concat
         'use_arrow_functions' => false, // TODO switch on when # of PR's is lower
     ])
     ->setFinder($finder)
diff --git a/dev-tools/composer.lock b/dev-tools/composer.lock
index 952b8258d..e7ee61949 100644
--- a/dev-tools/composer.lock
+++ b/dev-tools/composer.lock
@@ -2488,16 +2488,16 @@
         },
         {
             "name": "phpstan/phpstan",
-            "version": "1.8.0",
+            "version": "1.8.3",
             "source": {
                 "type": "git",
                 "url": "https://github.com/phpstan/phpstan.git",
-                "reference": "b7648d4ee9321665acaf112e49da9fd93df8fbd5"
+                "reference": "5583623b61caafebd62bc78a99533aa9d769d097"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/b7648d4ee9321665acaf112e49da9fd93df8fbd5",
-                "reference": "b7648d4ee9321665acaf112e49da9fd93df8fbd5",
+                "url": "https://api.github.com/repos/phpstan/phpstan/zipball/5583623b61caafebd62bc78a99533aa9d769d097",
+                "reference": "5583623b61caafebd62bc78a99533aa9d769d097",
                 "shasum": ""
             },
             "require": {
@@ -2521,9 +2521,13 @@
                 "MIT"
             ],
             "description": "PHPStan - PHP Static Analysis Tool",
+            "keywords": [
+                "dev",
+                "static analysis"
+            ],
             "support": {
                 "issues": "https://github.com/phpstan/phpstan/issues",
-                "source": "https://github.com/phpstan/phpstan/tree/1.8.0"
+                "source": "https://github.com/phpstan/phpstan/tree/1.8.3"
             },
             "funding": [
                 {
@@ -2534,16 +2538,12 @@
                     "url": "https://github.com/phpstan",
                     "type": "github"
                 },
-                {
-                    "url": "https://www.patreon.com/phpstan",
-                    "type": "patreon"
-                },
                 {
                     "url": "https://tidelift.com/funding/github/packagist/phpstan/phpstan",
                     "type": "tidelift"
                 }
             ],
-            "time": "2022-06-29T08:53:31+00:00"
+            "time": "2022-09-01T15:27:34+00:00"
         },
         {
             "name": "phpstan/phpstan-phpunit",
diff --git a/doc/list.rst b/doc/list.rst
index 639c91f18..73d5926ea 100644
--- a/doc/list.rst
+++ b/doc/list.rst
@@ -1749,6 +1749,21 @@ List of Available Rules
    Part of rule sets `@PhpCsFixer <./ruleSets/PhpCsFixer.rst>`_ `@Symfony <./ruleSets/Symfony.rst>`_
 
    `Source PhpCsFixer\\Fixer\\Import\\NoUnusedImportsFixer <./../src/Fixer/Import/NoUnusedImportsFixer.php>`_
+-  `no_useless_concat_operator <./rules/operator/no_useless_concat_operator.rst>`_
+
+   There should not be useless concat operations.
+
+   Configuration options:
+
+   - | ``juggle_simple_strings``
+     | Allow for simple string quote juggling if it results in more concat-operations merges.
+     | Allowed types: ``bool``
+     | Default value: ``false``
+
+
+   Part of rule sets `@PhpCsFixer <./ruleSets/PhpCsFixer.rst>`_ `@Symfony <./ruleSets/Symfony.rst>`_
+
+   `Source PhpCsFixer\\Fixer\\Operator\\NoUselessConcatOperatorFixer <./../src/Fixer/Operator/NoUselessConcatOperatorFixer.php>`_
 -  `no_useless_else <./rules/control_structure/no_useless_else.rst>`_
 
    There should not be useless ``else`` cases.
diff --git a/doc/ruleSets/Symfony.rst b/doc/ruleSets/Symfony.rst
index 336e7a697..548a760a3 100644
--- a/doc/ruleSets/Symfony.rst
+++ b/doc/ruleSets/Symfony.rst
@@ -78,6 +78,7 @@ Rules
 - `no_unneeded_import_alias <./../rules/import/no_unneeded_import_alias.rst>`_
 - `no_unset_cast <./../rules/cast_notation/no_unset_cast.rst>`_
 - `no_unused_imports <./../rules/import/no_unused_imports.rst>`_
+- `no_useless_concat_operator <./../rules/operator/no_useless_concat_operator.rst>`_
 - `no_useless_nullsafe_operator <./../rules/operator/no_useless_nullsafe_operator.rst>`_
 - `no_whitespace_before_comma_in_array <./../rules/array_notation/no_whitespace_before_comma_in_array.rst>`_
 - `normalize_index_brace <./../rules/array_notation/normalize_index_brace.rst>`_
diff --git a/doc/rules/index.rst b/doc/rules/index.rst
index 33c555691..64de87c2c 100644
--- a/doc/rules/index.rst
+++ b/doc/rules/index.rst
@@ -526,6 +526,9 @@ Operator
 - `no_space_around_double_colon <./operator/no_space_around_double_colon.rst>`_
 
   There must be no space around double colons (also called Scope Resolution Operator or Paamayim Nekudotayim).
+- `no_useless_concat_operator <./operator/no_useless_concat_operator.rst>`_
+
+  There should not be useless concat operations.
 - `no_useless_nullsafe_operator <./operator/no_useless_nullsafe_operator.rst>`_
 
   There should not be useless ``null-safe-operators`` ``?->`` used.
diff --git a/doc/rules/operator/no_useless_concat_operator.rst b/doc/rules/operator/no_useless_concat_operator.rst
new file mode 100644
index 000000000..b438f6738
--- /dev/null
+++ b/doc/rules/operator/no_useless_concat_operator.rst
@@ -0,0 +1,58 @@
+===================================
+Rule ``no_useless_concat_operator``
+===================================
+
+There should not be useless concat operations.
+
+Configuration
+-------------
+
+``juggle_simple_strings``
+~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Allow for simple string quote juggling if it results in more concat-operations
+merges.
+
+Allowed types: ``bool``
+
+Default value: ``false``
+
+Examples
+--------
+
+Example #1
+~~~~~~~~~~
+
+*Default* configuration.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+   -$a = 'a'.'b';
+   +$a = 'ab';
+
+Example #2
+~~~~~~~~~~
+
+With configuration: ``['juggle_simple_strings' => true]``.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+   -$a = 'a'."b";
+   +$a = "ab";
+
+Rule sets
+---------
+
+The rule is part of the following rule sets:
+
+@PhpCsFixer
+  Using the `@PhpCsFixer <./../../ruleSets/PhpCsFixer.rst>`_ rule set will enable the ``no_useless_concat_operator`` rule with the default config.
+
+@Symfony
+  Using the `@Symfony <./../../ruleSets/Symfony.rst>`_ rule set will enable the ``no_useless_concat_operator`` rule with the default config.
diff --git a/src/Doctrine/Annotation/Tokens.php b/src/Doctrine/Annotation/Tokens.php
index 3f7a47789..d1e7ad8bf 100644
--- a/src/Doctrine/Annotation/Tokens.php
+++ b/src/Doctrine/Annotation/Tokens.php
@@ -273,6 +273,7 @@ public function offsetUnset($index): void
 
         $max = \count($this) - 1;
         while ($index < $max) {
+            // @phpstan-ignore-next-line Next index always exists.
             $this[$index] = $this[$index + 1];
             ++$index;
         }
diff --git a/src/Fixer/Alias/BacktickToShellExecFixer.php b/src/Fixer/Alias/BacktickToShellExecFixer.php
index c778c084d..eca32ba64 100644
--- a/src/Fixer/Alias/BacktickToShellExecFixer.php
+++ b/src/Fixer/Alias/BacktickToShellExecFixer.php
@@ -63,7 +63,7 @@ public function getDefinition(): FixerDefinitionInterface
      */
     public function getPriority(): int
     {
-        return 2;
+        return 17;
     }
 
     /**
diff --git a/src/Fixer/Alias/EregToPregFixer.php b/src/Fixer/Alias/EregToPregFixer.php
index 40537fc2a..7b6b18ca9 100644
--- a/src/Fixer/Alias/EregToPregFixer.php
+++ b/src/Fixer/Alias/EregToPregFixer.php
@@ -60,6 +60,16 @@ public function getDefinition(): FixerDefinitionInterface
         );
     }
 
+    /**
+     * {@inheritdoc}
+     *
+     * Must run after NoUselessConcatOperatorFixer.
+     */
+    public function getPriority(): int
+    {
+        return 0;
+    }
+
     /**
      * {@inheritdoc}
      */
diff --git a/src/Fixer/Alias/SetTypeToCastFixer.php b/src/Fixer/Alias/SetTypeToCastFixer.php
index 82215e24a..571cfe3a4 100644
--- a/src/Fixer/Alias/SetTypeToCastFixer.php
+++ b/src/Fixer/Alias/SetTypeToCastFixer.php
@@ -48,7 +48,7 @@ public function getDefinition(): FixerDefinitionInterface
     /**
      * {@inheritdoc}
      *
-     * Must run after NoBinaryStringFixer.
+     * Must run after NoBinaryStringFixer, NoUselessConcatOperatorFixer.
      */
     public function getPriority(): int
     {
diff --git a/src/Fixer/FunctionNotation/DateTimeCreateFromFormatCallFixer.php b/src/Fixer/FunctionNotation/DateTimeCreateFromFormatCallFixer.php
index de78cdbb5..aa55978bb 100644
--- a/src/Fixer/FunctionNotation/DateTimeCreateFromFormatCallFixer.php
+++ b/src/Fixer/FunctionNotation/DateTimeCreateFromFormatCallFixer.php
@@ -42,6 +42,16 @@ public function getDefinition(): FixerDefinitionInterface
         );
     }
 
+    /**
+     * {@inheritdoc}
+     *
+     * Must run after NoUselessConcatOperatorFixer.
+     */
+    public function getPriority(): int
+    {
+        return 0;
+    }
+
     public function isCandidate(Tokens $tokens): bool
     {
         return $tokens->isTokenKindFound(T_DOUBLE_COLON);
diff --git a/src/Fixer/FunctionNotation/RegularCallableCallFixer.php b/src/Fixer/FunctionNotation/RegularCallableCallFixer.php
index a06776b5e..81d9fed5c 100644
--- a/src/Fixer/FunctionNotation/RegularCallableCallFixer.php
+++ b/src/Fixer/FunctionNotation/RegularCallableCallFixer.php
@@ -62,7 +62,7 @@ public function getDefinition(): FixerDefinitionInterface
      * {@inheritdoc}
      *
      * Must run before NativeFunctionInvocationFixer.
-     * Must run after NoBinaryStringFixer.
+     * Must run after NoBinaryStringFixer, NoUselessConcatOperatorFixer.
      */
     public function getPriority(): int
     {
diff --git a/src/Fixer/Operator/NoUselessConcatOperatorFixer.php b/src/Fixer/Operator/NoUselessConcatOperatorFixer.php
new file mode 100644
index 000000000..95310ac55
--- /dev/null
+++ b/src/Fixer/Operator/NoUselessConcatOperatorFixer.php
@@ -0,0 +1,351 @@
+<?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\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;
+
+final class NoUselessConcatOperatorFixer extends AbstractFixer implements ConfigurableFixerInterface
+{
+    private const STR_DOUBLE_QUOTE = 0;
+    private const STR_DOUBLE_QUOTE_VAR = 1;
+    private const STR_SINGLE_QUOTE = 2;
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getDefinition(): FixerDefinitionInterface
+    {
+        return new FixerDefinition(
+            'There should not be useless concat operations.',
+            [
+                new CodeSample("<?php\n\$a = 'a'.'b';\n"),
+                new CodeSample("<?php\n\$a = 'a'.\"b\";\n", ['juggle_simple_strings' => true]),
+            ],
+        );
+    }
+
+    /**
+     * {@inheritdoc}
+     *
+     * Must run before DateTimeCreateFromFormatCallFixer, EregToPregFixer, PhpUnitDedicateAssertInternalTypeFixer, RegularCallableCallFixer, SetTypeToCastFixer.
+     * Must run after NoBinaryStringFixer, SingleQuoteFixer.
+     */
+    public function getPriority(): int
+    {
+        return 5;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function isCandidate(Tokens $tokens): bool
+    {
+        return $tokens->isTokenKindFound('.') && $tokens->isAnyTokenKindsFound([T_CONSTANT_ENCAPSED_STRING, '"']);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
+    {
+        for ($index = $tokens->count() - 1; $index > 0; --$index) {
+            if (!$tokens[$index]->equals('.')) {
+                continue;
+            }
+
+            $nextMeaningfulTokenIndex = $tokens->getNextMeaningfulToken($index);
+
+            if ($this->containsLinebreak($tokens, $index, $nextMeaningfulTokenIndex)) {
+                continue;
+            }
+
+            $secondOperand = $this->getConcatOperandType($tokens, $nextMeaningfulTokenIndex, 1);
+
+            if (null === $secondOperand) {
+                continue;
+            }
+
+            $prevMeaningfulTokenIndex = $tokens->getPrevMeaningfulToken($index);
+
+            if ($this->containsLinebreak($tokens, $prevMeaningfulTokenIndex, $index)) {
+                continue;
+            }
+
+            $firstOperand = $this->getConcatOperandType($tokens, $prevMeaningfulTokenIndex, -1);
+
+            if (null === $firstOperand) {
+                continue;
+            }
+
+            $this->fixConcatOperation($tokens, $firstOperand, $index, $secondOperand);
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
+    {
+        return new FixerConfigurationResolver([
+            (new FixerOptionBuilder('juggle_simple_strings', 'Allow for simple string quote juggling if it results in more concat-operations merges.'))
+                ->setAllowedTypes(['bool'])
+                ->setDefault(false)
+                ->getOption(),
+        ]);
+    }
+
+    /**
+     * @param array{
+     *     start: int,
+     *     end: int,
+     *     type: self::STR_*,
+     * } $firstOperand
+     * @param array{
+     *     start: int,
+     *     end: int,
+     *     type: self::STR_*,
+     * } $secondOperand
+     */
+    private function fixConcatOperation(Tokens $tokens, array $firstOperand, int $concatIndex, array $secondOperand): void
+    {
+        // if both operands are of the same type then these operands can always be merged
+
+        if (
+            (self::STR_DOUBLE_QUOTE === $firstOperand['type'] && self::STR_DOUBLE_QUOTE === $secondOperand['type'])
+            || (self::STR_SINGLE_QUOTE === $firstOperand['type'] && self::STR_SINGLE_QUOTE === $secondOperand['type'])
+        ) {
+            $this->mergeContantEscapedStringOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
+
+            return;
+        }
+
+        if (self::STR_DOUBLE_QUOTE_VAR === $firstOperand['type'] && self::STR_DOUBLE_QUOTE_VAR === $secondOperand['type']) {
+            $this->mergeContantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
+
+            return;
+        }
+
+        // if any is double and the other is not, check for simple other, than merge with "
+
+        $operands = [
+            [$firstOperand, $secondOperand],
+            [$secondOperand, $firstOperand],
+        ];
+
+        foreach ($operands as $operandPair) {
+            [$operand1, $operand2] = $operandPair;
+
+            if (self::STR_DOUBLE_QUOTE_VAR === $operand1['type'] && self::STR_DOUBLE_QUOTE === $operand2['type']) {
+                $this->mergeContantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
+
+                return;
+            }
+
+            if (!$this->configuration['juggle_simple_strings']) {
+                continue;
+            }
+
+            if (self::STR_DOUBLE_QUOTE === $operand1['type'] && self::STR_SINGLE_QUOTE === $operand2['type']) {
+                $operantContent = $tokens[$operand2['start']]->getContent();
+
+                if ($this->isSimpleQuotedStringContent($operantContent)) {
+                    $this->mergeContantEscapedStringOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
+                }
+
+                return;
+            }
+
+            if (self::STR_DOUBLE_QUOTE_VAR === $operand1['type'] && self::STR_SINGLE_QUOTE === $operand2['type']) {
+                $operantContent = $tokens[$operand2['start']]->getContent();
+
+                if ($this->isSimpleQuotedStringContent($operantContent)) {
+                    $this->mergeContantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
+                }
+
+                return;
+            }
+        }
+    }
+
+    /**
+     * @param -1|1 $direction
+     *
+     * @return null|array{
+     *     start: int,
+     *     end: int,
+     *     type: self::STR_*,
+     * }
+     */
+    private function getConcatOperandType(Tokens $tokens, int $index, int $direction): ?array
+    {
+        if ($tokens[$index]->isGivenKind(T_CONSTANT_ENCAPSED_STRING)) {
+            $firstChar = $tokens[$index]->getContent();
+
+            if ('b' === $firstChar[0] || 'B' === $firstChar[0]) {
+                return null; // we don't care about these, priorities are set to do deal with these cases
+            }
+
+            return [
+                'start' => $index,
+                'end' => $index,
+                'type' => '"' === $firstChar[0] ? self::STR_DOUBLE_QUOTE : self::STR_SINGLE_QUOTE,
+            ];
+        }
+
+        if ($tokens[$index]->equals('"')) {
+            $end = $tokens->getTokenOfKindSibling($index, $direction, ['"']);
+
+            return [
+                'start' => 1 === $direction ? $index : $end,
+                'end' => 1 === $direction ? $end : $index,
+                'type' => self::STR_DOUBLE_QUOTE_VAR,
+            ];
+        }
+
+        return null;
+    }
+
+    /**
+     * @param array{
+     *     start: int,
+     *     end: int,
+     *     type: self::STR_*,
+     * } $firstOperand
+     * @param array{
+     *     start: int,
+     *     end: int,
+     *     type: self::STR_*,
+     * } $secondOperand
+     */
+    private function mergeContantEscapedStringOperands(
+        Tokens $tokens,
+        array $firstOperand,
+        int $concatOperatorIndex,
+        array $secondOperand
+    ): void {
+        $quote = self::STR_DOUBLE_QUOTE === $firstOperand['type'] || self::STR_DOUBLE_QUOTE === $secondOperand['type'] ? '"' : "'";
+        $firstOperandTokenContent = $tokens[$firstOperand['start']]->getContent();
+        $secondOperandTokenContent = $tokens[$secondOperand['start']]->getContent();
+
+        $tokens[$firstOperand['start']] = new Token(
+            [
+                T_CONSTANT_ENCAPSED_STRING,
+                $quote.substr($firstOperandTokenContent, 1, -1).substr($secondOperandTokenContent, 1, -1).$quote,
+            ],
+        );
+
+        $tokens->clearTokenAndMergeSurroundingWhitespace($secondOperand['start']);
+        $this->clearConcatAndAround($tokens, $concatOperatorIndex);
+    }
+
+    /**
+     * @param array{
+     *     start: int,
+     *     end: int,
+     *     type: self::STR_*,
+     * } $firstOperand
+     * @param array{
+     *     start: int,
+     *     end: int,
+     *     type: self::STR_*,
+     * } $secondOperand
+     */
+    private function mergeContantEscapedStringVarOperands(
+        Tokens $tokens,
+        array $firstOperand,
+        int $concatOperatorIndex,
+        array $secondOperand
+    ): void {
+        // build uo the new content
+        $newContent = '';
+
+        foreach ([$firstOperand, $secondOperand] as $operant) {
+            $operandContent = '';
+
+            for ($i = $operant['start']; $i <= $operant['end'];) {
+                $operandContent .= $tokens[$i]->getContent();
+                $i = $tokens->getNextMeaningfulToken($i);
+            }
+
+            $newContent .= substr($operandContent, 1, -1);
+        }
+
+        // remove tokens making up the concat statement
+
+        for ($i = $secondOperand['end']; $i >= $secondOperand['start'];) {
+            $tokens->clearTokenAndMergeSurroundingWhitespace($i);
+            $i = $tokens->getPrevMeaningfulToken($i);
+        }
+
+        $this->clearConcatAndAround($tokens, $concatOperatorIndex);
+
+        for ($i = $firstOperand['end']; $i > $firstOperand['start'];) {
+            $tokens->clearTokenAndMergeSurroundingWhitespace($i);
+            $i = $tokens->getPrevMeaningfulToken($i);
+        }
+
+        // insert new tokens based on the new content
+
+        $newTokens = Tokens::fromCode('<?php "'.$newContent.'";');
+        $newTokensCount = \count($newTokens);
+
+        $insertTokens = [];
+
+        for ($i = 1; $i < $newTokensCount - 1; ++$i) {
+            $insertTokens[] = $newTokens[$i];
+        }
+
+        $tokens->overrideRange($firstOperand['start'], $firstOperand['start'], $insertTokens);
+    }
+
+    private function clearConcatAndAround(Tokens $tokens, int $concatOperatorIndex): void
+    {
+        if ($tokens[$concatOperatorIndex + 1]->isWhitespace()) {
+            $tokens->clearTokenAndMergeSurroundingWhitespace($concatOperatorIndex + 1);
+        }
+
+        $tokens->clearTokenAndMergeSurroundingWhitespace($concatOperatorIndex);
+
+        if ($tokens[$concatOperatorIndex - 1]->isWhitespace()) {
+            $tokens->clearTokenAndMergeSurroundingWhitespace($concatOperatorIndex - 1);
+        }
+    }
+
+    private function isSimpleQuotedStringContent(string $candidate): bool
+    {
+        return 0 === Preg::match('#[\$"\'\\\]#', substr($candidate, 1, -1));
+    }
+
+    private function containsLinebreak(Tokens $tokens, int $startIndex, int $endIndex): bool
+    {
+        for ($i = $endIndex; $i > $startIndex; --$i) {
+            if (Preg::match('/\R/', $tokens[$i]->getContent())) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+}
diff --git a/src/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixer.php b/src/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixer.php
index 45e2ca3d4..30de49349 100644
--- a/src/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixer.php
+++ b/src/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixer.php
@@ -100,7 +100,7 @@ public function isRisky(): bool
     /**
      * {@inheritdoc}
      *
-     * Must run after NoBinaryStringFixer, PhpUnitDedicateAssertFixer.
+     * Must run after NoBinaryStringFixer, NoUselessConcatOperatorFixer, PhpUnitDedicateAssertFixer.
      */
     public function getPriority(): int
     {
diff --git a/src/Fixer/StringNotation/EscapeImplicitBackslashesFixer.php b/src/Fixer/StringNotation/EscapeImplicitBackslashesFixer.php
index d05e5139f..b84e766af 100644
--- a/src/Fixer/StringNotation/EscapeImplicitBackslashesFixer.php
+++ b/src/Fixer/StringNotation/EscapeImplicitBackslashesFixer.php
@@ -92,7 +92,7 @@ public function isCandidate(Tokens $tokens): bool
      */
     public function getPriority(): int
     {
-        return 1;
+        return 15;
     }
 
     /**
diff --git a/src/Fixer/StringNotation/NoBinaryStringFixer.php b/src/Fixer/StringNotation/NoBinaryStringFixer.php
index aa55f6794..ccb8bb5a6 100644
--- a/src/Fixer/StringNotation/NoBinaryStringFixer.php
+++ b/src/Fixer/StringNotation/NoBinaryStringFixer.php
@@ -57,7 +57,7 @@ public function getDefinition(): FixerDefinitionInterface
     /**
      * {@inheritdoc}
      *
-     * Must run before PhpUnitDedicateAssertInternalTypeFixer, RegularCallableCallFixer, SetTypeToCastFixer.
+     * Must run before NoUselessConcatOperatorFixer, PhpUnitDedicateAssertInternalTypeFixer, RegularCallableCallFixer, SetTypeToCastFixer.
      */
     public function getPriority(): int
     {
diff --git a/src/Fixer/StringNotation/SingleQuoteFixer.php b/src/Fixer/StringNotation/SingleQuoteFixer.php
index b269b418c..21a565c8f 100644
--- a/src/Fixer/StringNotation/SingleQuoteFixer.php
+++ b/src/Fixer/StringNotation/SingleQuoteFixer.php
@@ -59,11 +59,12 @@ public function getDefinition(): FixerDefinitionInterface
     /**
      * {@inheritdoc}
      *
+     * Must run before NoUselessConcatOperatorFixer.
      * Must run after BacktickToShellExecFixer, EscapeImplicitBackslashesFixer.
      */
     public function getPriority(): int
     {
-        return 0;
+        return 10;
     }
 
     /**
diff --git a/src/RuleSet/Sets/SymfonySet.php b/src/RuleSet/Sets/SymfonySet.php
index 983cec51c..4703c5796 100644
--- a/src/RuleSet/Sets/SymfonySet.php
+++ b/src/RuleSet/Sets/SymfonySet.php
@@ -123,6 +123,7 @@ public function getRules(): array
             'no_unneeded_import_alias' => true,
             'no_unset_cast' => true,
             'no_unused_imports' => true,
+            'no_useless_concat_operator' => true,
             'no_useless_nullsafe_operator' => true,
             'no_whitespace_before_comma_in_array' => true,
             'normalize_index_brace' => true,
diff --git a/src/Tokenizer/Analyzer/ControlCaseStructuresAnalyzer.php b/src/Tokenizer/Analyzer/ControlCaseStructuresAnalyzer.php
index 489d8fa15..3dd3d1a38 100644
--- a/src/Tokenizer/Analyzer/ControlCaseStructuresAnalyzer.php
+++ b/src/Tokenizer/Analyzer/ControlCaseStructuresAnalyzer.php
@@ -48,6 +48,17 @@ public static function findControlStructures(Tokens $tokens, array $types): \Gen
         }
 
         $depth = -1;
+
+        /**
+         * @var list<array{
+         *     kind: int|null,
+         *     index: int,
+         *     brace_count: int,
+         *     cases: list<array{index: int, open: int}>,
+         *     default: array{index: int, open: int}|null,
+         *     alternative_syntax: bool,
+         * }> $stack
+         */
         $stack = [];
         $isTypeOfInterest = false;
 
diff --git a/src/Tokenizer/Token.php b/src/Tokenizer/Token.php
index 6d740b74e..0c5e8de67 100644
--- a/src/Tokenizer/Token.php
+++ b/src/Tokenizer/Token.php
@@ -186,6 +186,11 @@ public function equals($other, bool $caseSensitive = true): bool
         // detect unknown keys
         unset($otherPrototype[0], $otherPrototype[1]);
 
+        /*
+         * @phpstan-ignore-next-line This validation is required when the method
+         *                           is called in a codebase that does not use
+         *                           static analysis.
+         */
         return empty($otherPrototype);
     }
 


Test patch:
diff --git a/tests/AutoReview/FixerFactoryTest.php b/tests/AutoReview/FixerFactoryTest.php
index 312ad918a..4691cdf03 100644
--- a/tests/AutoReview/FixerFactoryTest.php
+++ b/tests/AutoReview/FixerFactoryTest.php
@@ -517,6 +517,7 @@ private static function getFixersPriorityGraph(): array
                 'switch_continue_to_break',
             ],
             'no_binary_string' => [
+                'no_useless_concat_operator',
                 'php_unit_dedicate_assert_internal_type',
                 'regular_callable_call',
                 'set_type_to_cast',
@@ -608,6 +609,13 @@ private static function getFixersPriorityGraph(): array
                 'no_leading_import_slash',
                 'single_line_after_imports',
             ],
+            'no_useless_concat_operator' => [
+                'date_time_create_from_format_call',
+                'ereg_to_preg',
+                'php_unit_dedicate_assert_internal_type',
+                'regular_callable_call',
+                'set_type_to_cast',
+            ],
             'no_useless_else' => [
                 'braces',
                 'combine_consecutive_unsets',
@@ -776,6 +784,9 @@ private static function getFixersPriorityGraph(): array
                 'braces',
                 'concat_space',
             ],
+            'single_quote' => [
+                'no_useless_concat_operator',
+            ],
             'single_space_after_construct' => [
                 'braces',
                 'function_declaration',
diff --git a/tests/Fixer/Operator/NoUselessConcatOperatorFixerTest.php b/tests/Fixer/Operator/NoUselessConcatOperatorFixerTest.php
new file mode 100644
index 000000000..4ef9c7c2b
--- /dev/null
+++ b/tests/Fixer/Operator/NoUselessConcatOperatorFixerTest.php
@@ -0,0 +1,176 @@
+<?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;
+
+/**
+ * @internal
+ *
+ * @covers \PhpCsFixer\Fixer\Operator\NoUselessConcatOperatorFixer
+ */
+final class NoUselessConcatOperatorFixerTest extends AbstractFixerTestCase
+{
+    /**
+     * @dataProvider provideFixCases
+     */
+    public function testFix(string $expected, ?string $input = null): void
+    {
+        $this->fixer->configure(['juggle_simple_strings' => true]);
+        $this->doTest($expected, $input);
+    }
+
+    public function provideFixCases(): iterable
+    {
+        $templateExpected = '<?php $b = %s;';
+        $templateInput = '<?php $b = %s.%s;';
+        $cases = [
+            'single . single' => ["'a'", "'b'", "'ab'"],
+            'single . double' => ["'a'", '"b"', '"ab"'],
+            'double . single' => ['"b\n"', "'a'", '"b\na"'],
+            'double . double' => ['"b"', '"b"', '"bb"'],
+            'encapsed . encapsed' => ['"{$a}"', '"{$b}"', '"{$a}{$b}"'],
+            'encapsed space. encapsed' => ['"{$a1} "', '"{$b2}"', '"{$a1} {$b2}"'],
+            'encapsed . space encapsed' => ['"{$a}"', '" {$b}"', '"{$a} {$b}"'],
+            'encapsed space. space encapsed' => ['"{$a} "', '" {$b}"', '"{$a}  {$b}"'],
+            'encapsed . single' => ['"{$a}"', "'Z'", '"{$a}Z"'],
+            'single . encapsed' => ["'Y'", '"{$a}"', '"Y{$a}"'],
+            'encapsed spaced. single' => ['"{$a}   "', "'G'", '"{$a}   G"'],
+            'single . space encapsed' => ["'V'", '" {$a}"', '"V {$a}"'],
+            'encapsed . double' => ['"{$a} "', '"XX"', '"{$a} XX"'],
+            'double . encapsed' => ['"XCV"', '"{$a}"', '"XCV{$a}"'],
+            'encapsed spaced . double' => ['"{$a} V "', '"PO"', '"{$a} V PO"'],
+            'double . space encapsed' => ['"DSA"', '"   XX {$a}"', '"DSA   XX {$a}"'],
+        ];
+
+        foreach ($cases as $label => $case) {
+            yield $label => [
+                sprintf($templateExpected, $case[2]),
+                sprintf($templateInput, $case[0], $case[1]),
+            ];
+        }
+
+        yield 'encapsed followed by simple double quoted 1' => [
+            '<?php echo "Hello, {$fruit}s.";',
+            '<?php echo \'Hello,\'  .  " {$fruit}s.";',
+        ];
+
+        yield 'encapsed followed by simple double quoted 2' => [
+            '<?php echo "Hello.He drank some juice made of {$fruit}s.Bye $user!" /*1*//*2*/ /*3*//*4*/;',
+            '<?php echo \'Hello.\' /*1*/ . /*2*/ "He drank some juice made of {$fruit}s."/*3*/./*4*/"Bye $user!";',
+        ];
+
+        yield [
+            '<?php
+$string = "foobar";
+echo "char @ -4 \"[$string[-2]]\"!";
+',
+            '<?php
+$string = "foobar";
+echo "char @ -4 \"[$string[-2]"."]\"!";
+',
+        ];
+
+        yield 'double quote concat double quote + comment' => [
+            '<?php $fi = "lk" /* 1 *//* 2 */ ;',
+            '<?php $fi = "l" /* 1 */ . /* 2 */ "k";',
+        ];
+
+        yield 'empty concat empty' => [
+            '<?php $aT = "";',
+            '<?php $aT = ""."";',
+        ];
+
+        yield 'multiple fixes' => [
+            '<?php $f0 = "abc | defg";',
+            '<?php $f0 = "a"."b"."c | "."d"."e"."f"."g";',
+        ];
+
+        yield 'linebreak with indent inside' => [
+            '<?php
+$text1 = "intro:   |   |"."
+               line 2 indent";',
+            '<?php
+$text1 = "intro:   |"."   |"."
+               line 2 indent"."";',
+        ];
+
+        yield 'linebreak with indent inside + comment' => [
+            '<?php
+$text2 = "intro:      "." #a
+               line 2 indent";',
+            '<?php
+$text2 = "intro:   "."   "." #a
+               line 2 indent"."";',
+        ];
+
+        yield 'do not fix' => [
+            '<?php
+                $a0x = $b . "c";
+                $a1x = "c" . $b;
+                $b2x = foo() . "X";
+                $b3x = foo() . \'Y\';
+                $b4x = "Z" . foo();
+                $b5x = \'b\' . foo();
+                $b6x = \'X  \n \' . "\n\t";
+                $b7x = "\n\t" . \'X $a\';
+                $b7x = "abc". 1;
+                $b7x = "def". 1.2;
+                // bin string
+                $f202 = b"a"."b";
+                $f201 = b"a".b"b";
+                $f203 = "a".B"b";
+                echo b"He drank some juice made of {$fruit}s.".b" Sliced the {$fruit}s.";
+            ',
+        ];
+
+        yield 'single quote concat single quote but with line break after' => [
+            "<?php \$fh = 'x'. // some comment
+'y';",
+        ];
+
+        yield 'single quote concat single quote but with line break before' => [
+            "<?php \$ff = 'x' // some comment
+.'y';",
+        ];
+
+        yield 'linebreak without indent inside' => [
+            '<?php
+$text3 = "intro:"."
+line 2 indent" ?>',
+        ];
+
+        yield 'linebreak before concat + comment' => [
+            "<?php
+\$a = 'The first line of some block.'
+.'The second line' // some comment about this line
+.'3rd line'
+;
+",
+        ];
+    }
+
+    public function testWithConfigJuggling(): void
+    {
+        $input = '<?php $a = "x" . \'y\';';
+        $expected = '<?php $a = "xy";';
+
+        $this->fixer->configure(['juggle_simple_strings' => true]);
+        $this->doTest($expected, $input);
+
+        $this->fixer->configure(['juggle_simple_strings' => false]);
+        $this->doTest($input);
+    }
+}
diff --git a/tests/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixerTest.php b/tests/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixerTest.php
index 4000147f7..3853ae5a3 100644
--- a/tests/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixerTest.php
+++ b/tests/Fixer/PhpUnit/PhpUnitDedicateAssertInternalTypeFixerTest.php
@@ -33,11 +33,10 @@ public function testFixInternalType(string $expected, ?string $input = null): vo
         $this->doTest($expected, $input);
     }
 
-    public function provideTestFixInternalTypeCases(): array
+    public function provideTestFixInternalTypeCases(): iterable
     {
-        return [
-            'skip cases' => [
-                '<?php
+        yield 'skip cases' => [
+            '<?php
 final class MyTest extends \PhpUnit\FrameWork\TestCase
 {
     public function testMe()
@@ -56,9 +55,10 @@ public function testMe()
     }
 }
 ',
-            ],
-            'expected normal cases' => [
-                '<?php
+        ];
+
+        yield 'expected normal cases' => [
+            '<?php
 final class MyTest extends \PhpUnit\FrameWork\TestCase
 {
     public function testMe()
@@ -99,7 +99,7 @@ public function testMe()
     }
 }
 ',
-                '<?php
+            '<?php
 final class MyTest extends \PhpUnit\FrameWork\TestCase
 {
     public function testMe()
@@ -140,9 +140,10 @@ public function testMe()
     }
 }
 ',
-            ],
-            'false positive cases' => [
-                '<?php
+        ];
+
+        yield 'false positive cases' => [
+            '<?php
 final class MyTest extends \PhpUnit\FrameWork\TestCase
 {
     public function testMe()
@@ -152,9 +153,10 @@ public function testMe()
     }
 }
 ',
-            ],
-            'anonymous class false positive case' => [
-                '<?php
+        ];
+
+        yield 'anonymous class false positive case' => [
+            '<?php
 final class MyTest extends \PhpUnit\FrameWork\TestCase
 {
     public function testMe()
@@ -169,7 +171,6 @@ private function foo(){
     }
 }
 ',
-            ],
         ];
     }
 }
diff --git a/tests/Fixtures/Integration/priority/no_binary_string,no_useless_concat_operator.test b/tests/Fixtures/Integration/priority/no_binary_string,no_useless_concat_operator.test
new file mode 100644
index 000000000..d4901f3ea
--- /dev/null
+++ b/tests/Fixtures/Integration/priority/no_binary_string,no_useless_concat_operator.test
@@ -0,0 +1,11 @@
+--TEST--
+Integration of fixers: no_binary_string,no_useless_concat_operator.
+--RULESET--
+{"no_binary_string": true, "no_useless_concat_operator": true}
+--EXPECT--
+<?php
+$f = "ab";
+
+--INPUT--
+<?php
+$f = b"a".b"b";
diff --git a/tests/Fixtures/Integration/priority/no_useless_concat_operator,date_time_create_from_format_call.test b/tests/Fixtures/Integration/priority/no_useless_concat_operator,date_time_create_from_format_call.test
new file mode 100644
index 000000000..4d9000000
--- /dev/null
+++ b/tests/Fixtures/Integration/priority/no_useless_concat_operator,date_time_create_from_format_call.test
@@ -0,0 +1,13 @@
+--TEST--
+Integration of fixers: no_useless_concat_operator,date_time_create_from_format_call.
+--RULESET--
+{"no_useless_concat_operator": true, "date_time_create_from_format_call": true}
+--EXPECT--
+<?php
+
+$f = \DateTime::createFromFormat('!Y-m-d', '2022-02-11');
+
+--INPUT--
+<?php
+
+$f = \DateTime::createFromFormat('Y-m'.'-d', '2022-02-11');
diff --git a/tests/Fixtures/Integration/priority/no_useless_concat_operator,ereg_to_preg.test b/tests/Fixtures/Integration/priority/no_useless_concat_operator,ereg_to_preg.test
new file mode 100644
index 000000000..8ad5c3424
--- /dev/null
+++ b/tests/Fixtures/Integration/priority/no_useless_concat_operator,ereg_to_preg.test
@@ -0,0 +1,13 @@
+--TEST--
+Integration of fixers: no_useless_concat_operator,ereg_to_preg.
+--RULESET--
+{"no_useless_concat_operator": true, "ereg_to_preg": true}
+--EXPECT--
+<?php
+
+$x = preg_match("#[AZ]/#D", $m);
+
+--INPUT--
+<?php
+
+$x = ereg("[A"."Z]/", $m);
diff --git a/tests/Fixtures/Integration/priority/no_useless_concat_operator,php_unit_dedicate_assert_internal_type.test b/tests/Fixtures/Integration/priority/no_useless_concat_operator,php_unit_dedicate_assert_internal_type.test
new file mode 100644
index 000000000..230e388c9
--- /dev/null
+++ b/tests/Fixtures/Integration/priority/no_useless_concat_operator,php_unit_dedicate_assert_internal_type.test
@@ -0,0 +1,25 @@
+--TEST--
+Integration of fixers: no_useless_concat_operator,php_unit_dedicate_assert_internal_type.
+--RULESET--
+{"no_useless_concat_operator": true, "php_unit_dedicate_assert_internal_type": true}
+--EXPECT--
+<?php
+
+final class MyTest extends \PhpUnit\FrameWork\TestCase
+{
+    public function testMe()
+    {
+        $this->assertIsArray($var);
+    }
+}
+
+--INPUT--
+<?php
+
+final class MyTest extends \PhpUnit\FrameWork\TestCase
+{
+    public function testMe()
+    {
+        $this->assertInternalType("ar"."ray", $var);
+    }
+}
diff --git a/tests/Fixtures/Integration/priority/no_useless_concat_operator,regular_callable_call.test b/tests/Fixtures/Integration/priority/no_useless_concat_operator,regular_callable_call.test
new file mode 100644
index 000000000..f1e357ae8
--- /dev/null
+++ b/tests/Fixtures/Integration/priority/no_useless_concat_operator,regular_callable_call.test
@@ -0,0 +1,13 @@
+--TEST--
+Integration of fixers: no_useless_concat_operator,regular_callable_call.
+--RULESET--
+{"no_useless_concat_operator": true, "regular_callable_call": true}
+--EXPECT--
+<?php
+
+foo(1,);
+
+--INPUT--
+<?php
+
+call_user_func("fo"."o", 1,);
diff --git a/tests/Fixtures/Integration/priority/no_useless_concat_operator,set_type_to_cast.test b/tests/Fixtures/Integration/priority/no_useless_concat_operator,set_type_to_cast.test
new file mode 100644
index 000000000..457931a82
--- /dev/null
+++ b/tests/Fixtures/Integration/priority/no_useless_concat_operator,set_type_to_cast.test
@@ -0,0 +1,13 @@
+--TEST--
+Integration of fixers: no_useless_concat_operator,set_type_to_cast.
+--RULESET--
+{"no_useless_concat_operator": true, "set_type_to_cast": true}
+--EXPECT--
+<?php
+
+$foo = (bool) $foo;
+
+--INPUT--
+<?php
+
+settype  (  $foo  , "Bo"."ol");
diff --git a/tests/Fixtures/Integration/priority/single_quote,no_useless_concat_operator.test b/tests/Fixtures/Integration/priority/single_quote,no_useless_concat_operator.test
new file mode 100644
index 000000000..e5419c0de
--- /dev/null
+++ b/tests/Fixtures/Integration/priority/single_quote,no_useless_concat_operator.test
@@ -0,0 +1,11 @@
+--TEST--
+Integration of fixers: single_quote,no_useless_concat_operator.
+--RULESET--
+{"single_quote": true, "no_useless_concat_operator": true}
+--EXPECT--
+<?php
+$a = 'foo $(bar7) $1';
+
+--INPUT--
+<?php
+$a = "foo \$(bar7)".' $1';