• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

keradus / PHP-CS-Fixer / 17252691116

26 Aug 2025 11:09PM UTC coverage: 94.743% (-0.01%) from 94.755%
17252691116

push

github

keradus
chore: apply phpdoc_tag_no_named_arguments

28313 of 29884 relevant lines covered (94.74%)

45.64 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.26
/src/Fixer/Operator/NoUselessConcatOperatorFixer.php
1
<?php
2

3
declare(strict_types=1);
4

5
/*
6
 * This file is part of PHP CS Fixer.
7
 *
8
 * (c) Fabien Potencier <fabien@symfony.com>
9
 *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
10
 *
11
 * This source file is subject to the MIT license that is bundled
12
 * with this source code in the file LICENSE.
13
 */
14

15
namespace PhpCsFixer\Fixer\Operator;
16

17
use PhpCsFixer\AbstractFixer;
18
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
19
use PhpCsFixer\Fixer\ConfigurableFixerTrait;
20
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
21
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
22
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
23
use PhpCsFixer\FixerDefinition\CodeSample;
24
use PhpCsFixer\FixerDefinition\FixerDefinition;
25
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
26
use PhpCsFixer\Preg;
27
use PhpCsFixer\Tokenizer\Token;
28
use PhpCsFixer\Tokenizer\Tokens;
29

30
/**
31
 * @phpstan-type _ConcatOperandType array{
32
 *     start: int,
33
 *     end: int,
34
 *     type: self::STR_*,
35
 * }
36
 * @phpstan-type _AutogeneratedInputConfiguration array{
37
 *  juggle_simple_strings?: bool,
38
 * }
39
 * @phpstan-type _AutogeneratedComputedConfiguration array{
40
 *  juggle_simple_strings: bool,
41
 * }
42
 *
43
 * @implements ConfigurableFixerInterface<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration>
44
 *
45
 * @no-named-arguments Parameter names are not covered by the backward compatibility promise.
46
 */
47
final class NoUselessConcatOperatorFixer extends AbstractFixer implements ConfigurableFixerInterface
48
{
49
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
50
    use ConfigurableFixerTrait;
51

52
    private const STR_DOUBLE_QUOTE = 0;
53
    private const STR_DOUBLE_QUOTE_VAR = 1;
54
    private const STR_SINGLE_QUOTE = 2;
55

56
    public function getDefinition(): FixerDefinitionInterface
57
    {
58
        return new FixerDefinition(
3✔
59
            'There should not be useless concat operations.',
3✔
60
            [
3✔
61
                new CodeSample("<?php\n\$a = 'a'.'b';\n"),
3✔
62
                new CodeSample("<?php\n\$a = 'a'.\"b\";\n", ['juggle_simple_strings' => true]),
3✔
63
            ],
3✔
64
        );
3✔
65
    }
66

67
    /**
68
     * {@inheritdoc}
69
     *
70
     * Must run before DateTimeCreateFromFormatCallFixer, EregToPregFixer, PhpUnitDedicateAssertInternalTypeFixer, RegularCallableCallFixer, SetTypeToCastFixer.
71
     * Must run after ExplicitStringVariableFixer, NoBinaryStringFixer, SingleQuoteFixer.
72
     */
73
    public function getPriority(): int
74
    {
75
        return 5;
1✔
76
    }
77

78
    public function isCandidate(Tokens $tokens): bool
79
    {
80
        return $tokens->isTokenKindFound('.') && $tokens->isAnyTokenKindsFound([\T_CONSTANT_ENCAPSED_STRING, '"']);
36✔
81
    }
82

83
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
84
    {
85
        for ($index = $tokens->count() - 1; $index > 0; --$index) {
36✔
86
            if (!$tokens[$index]->equals('.')) {
36✔
87
                continue;
36✔
88
            }
89

90
            $nextMeaningfulTokenIndex = $tokens->getNextMeaningfulToken($index);
36✔
91

92
            if ($this->containsLinebreak($tokens, $index, $nextMeaningfulTokenIndex)) {
36✔
93
                continue;
4✔
94
            }
95

96
            $secondOperand = $this->getConcatOperandType($tokens, $nextMeaningfulTokenIndex, 1);
34✔
97

98
            if (null === $secondOperand) {
34✔
99
                continue;
1✔
100
            }
101

102
            $prevMeaningfulTokenIndex = $tokens->getPrevMeaningfulToken($index);
34✔
103

104
            if ($this->containsLinebreak($tokens, $prevMeaningfulTokenIndex, $index)) {
34✔
105
                continue;
3✔
106
            }
107

108
            $firstOperand = $this->getConcatOperandType($tokens, $prevMeaningfulTokenIndex, -1);
32✔
109

110
            if (null === $firstOperand) {
32✔
111
                continue;
1✔
112
            }
113

114
            $this->fixConcatOperation($tokens, $firstOperand, $index, $secondOperand);
32✔
115
        }
116
    }
117

118
    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
119
    {
120
        return new FixerConfigurationResolver([
45✔
121
            (new FixerOptionBuilder('juggle_simple_strings', 'Allow for simple string quote juggling if it results in more concat-operations merges.'))
45✔
122
                ->setAllowedTypes(['bool'])
45✔
123
                ->setDefault(false)
45✔
124
                ->getOption(),
45✔
125
        ]);
45✔
126
    }
127

128
    /**
129
     * @param _ConcatOperandType $firstOperand
130
     * @param _ConcatOperandType $secondOperand
131
     */
132
    private function fixConcatOperation(Tokens $tokens, array $firstOperand, int $concatIndex, array $secondOperand): void
133
    {
134
        // if both operands are of the same type then these operands can always be merged
135

136
        if (
137
            (self::STR_DOUBLE_QUOTE === $firstOperand['type'] && self::STR_DOUBLE_QUOTE === $secondOperand['type'])
32✔
138
            || (self::STR_SINGLE_QUOTE === $firstOperand['type'] && self::STR_SINGLE_QUOTE === $secondOperand['type'])
32✔
139
        ) {
140
            $this->mergeConstantEscapedStringOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
9✔
141

142
            return;
9✔
143
        }
144

145
        if (self::STR_DOUBLE_QUOTE_VAR === $firstOperand['type'] && self::STR_DOUBLE_QUOTE_VAR === $secondOperand['type']) {
24✔
146
            if ($this->operandsCanNotBeMerged($tokens, $firstOperand, $secondOperand)) {
8✔
147
                return;
2✔
148
            }
149

150
            $this->mergeConstantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
6✔
151

152
            return;
6✔
153
        }
154

155
        // if any is double and the other is not, check for simple other, than merge with "
156

157
        $operands = [
20✔
158
            [$firstOperand, $secondOperand],
20✔
159
            [$secondOperand, $firstOperand],
20✔
160
        ];
20✔
161

162
        foreach ($operands as $operandPair) {
20✔
163
            [$operand1, $operand2] = $operandPair;
20✔
164

165
            if (self::STR_DOUBLE_QUOTE_VAR === $operand1['type'] && self::STR_DOUBLE_QUOTE === $operand2['type']) {
20✔
166
                if ($this->operandsCanNotBeMerged($tokens, $operand1, $operand2)) {
8✔
167
                    return;
2✔
168
                }
169

170
                $this->mergeConstantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
6✔
171

172
                return;
6✔
173
            }
174

175
            if (false === $this->configuration['juggle_simple_strings']) {
17✔
176
                continue;
1✔
177
            }
178

179
            if (self::STR_DOUBLE_QUOTE === $operand1['type'] && self::STR_SINGLE_QUOTE === $operand2['type']) {
16✔
180
                $operantContent = $tokens[$operand2['start']]->getContent();
5✔
181

182
                if ($this->isSimpleQuotedStringContent($operantContent)) {
5✔
183
                    $this->mergeConstantEscapedStringOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
4✔
184
                }
185

186
                return;
5✔
187
            }
188

189
            if (self::STR_DOUBLE_QUOTE_VAR === $operand1['type'] && self::STR_SINGLE_QUOTE === $operand2['type']) {
14✔
190
                $operantContent = $tokens[$operand2['start']]->getContent();
9✔
191

192
                if ($this->isSimpleQuotedStringContent($operantContent)) {
9✔
193
                    if ($this->operandsCanNotBeMerged($tokens, $operand1, $operand2)) {
9✔
194
                        return;
2✔
195
                    }
196

197
                    $this->mergeConstantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
7✔
198
                }
199

200
                return;
7✔
201
            }
202
        }
203
    }
204

205
    /**
206
     * @param -1|1 $direction
207
     *
208
     * @return null|_ConcatOperandType
209
     */
210
    private function getConcatOperandType(Tokens $tokens, int $index, int $direction): ?array
211
    {
212
        if ($tokens[$index]->isGivenKind(\T_CONSTANT_ENCAPSED_STRING)) {
34✔
213
            $firstChar = $tokens[$index]->getContent();
30✔
214

215
            if ('b' === $firstChar[0] || 'B' === $firstChar[0]) {
30✔
216
                return null; // we don't care about these, priorities are set to do deal with these cases
1✔
217
            }
218

219
            return [
30✔
220
                'start' => $index,
30✔
221
                'end' => $index,
30✔
222
                'type' => '"' === $firstChar[0] ? self::STR_DOUBLE_QUOTE : self::STR_SINGLE_QUOTE,
30✔
223
            ];
30✔
224
        }
225

226
        if ($tokens[$index]->equals('"')) {
19✔
227
            $end = $tokens->getTokenOfKindSibling($index, $direction, ['"']);
18✔
228

229
            return [
18✔
230
                'start' => 1 === $direction ? $index : $end,
18✔
231
                'end' => 1 === $direction ? $end : $index,
18✔
232
                'type' => self::STR_DOUBLE_QUOTE_VAR,
18✔
233
            ];
18✔
234
        }
235

236
        return null;
1✔
237
    }
238

239
    /**
240
     * @param _ConcatOperandType $firstOperand
241
     * @param _ConcatOperandType $secondOperand
242
     */
243
    private function mergeConstantEscapedStringOperands(
244
        Tokens $tokens,
245
        array $firstOperand,
246
        int $concatOperatorIndex,
247
        array $secondOperand
248
    ): void {
249
        $quote = self::STR_DOUBLE_QUOTE === $firstOperand['type'] || self::STR_DOUBLE_QUOTE === $secondOperand['type'] ? '"' : "'";
12✔
250
        $firstOperandTokenContent = $tokens[$firstOperand['start']]->getContent();
12✔
251
        $secondOperandTokenContent = $tokens[$secondOperand['start']]->getContent();
12✔
252

253
        $tokens[$firstOperand['start']] = new Token(
12✔
254
            [
12✔
255
                \T_CONSTANT_ENCAPSED_STRING,
12✔
256
                $quote.substr($firstOperandTokenContent, 1, -1).substr($secondOperandTokenContent, 1, -1).$quote,
12✔
257
            ],
12✔
258
        );
12✔
259

260
        $this->clearConcatAndAround($tokens, $concatOperatorIndex);
12✔
261
        $tokens->clearTokenAndMergeSurroundingWhitespace($secondOperand['start']);
12✔
262
    }
263

264
    /**
265
     * @param _ConcatOperandType $firstOperand
266
     * @param _ConcatOperandType $secondOperand
267
     */
268
    private function mergeConstantEscapedStringVarOperands(
269
        Tokens $tokens,
270
        array $firstOperand,
271
        int $concatOperatorIndex,
272
        array $secondOperand
273
    ): void {
274
        // build up the new content
275
        $newContent = '';
16✔
276

277
        foreach ([$firstOperand, $secondOperand] as $operant) {
16✔
278
            $operandContent = '';
16✔
279

280
            for ($i = $operant['start']; $i <= $operant['end'];) {
16✔
281
                $operandContent .= $tokens[$i]->getContent();
16✔
282
                $i = $tokens->getNextMeaningfulToken($i);
16✔
283
            }
284

285
            $newContent .= substr($operandContent, 1, -1);
16✔
286
        }
287

288
        // remove tokens making up the concat statement
289

290
        for ($i = $secondOperand['end']; $i >= $secondOperand['start'];) {
16✔
291
            $tokens->clearTokenAndMergeSurroundingWhitespace($i);
16✔
292
            $i = $tokens->getPrevMeaningfulToken($i);
16✔
293
        }
294

295
        $this->clearConcatAndAround($tokens, $concatOperatorIndex);
16✔
296

297
        for ($i = $firstOperand['end']; $i > $firstOperand['start'];) {
16✔
298
            $tokens->clearTokenAndMergeSurroundingWhitespace($i);
11✔
299
            $i = $tokens->getPrevMeaningfulToken($i);
11✔
300
        }
301

302
        // insert new tokens based on the new content
303

304
        $newTokens = Tokens::fromCode('<?php "'.$newContent.'";');
16✔
305
        $newTokensCount = \count($newTokens);
16✔
306

307
        $insertTokens = [];
16✔
308

309
        for ($i = 1; $i < $newTokensCount - 1; ++$i) {
16✔
310
            $insertTokens[] = $newTokens[$i];
16✔
311
        }
312

313
        $tokens->overrideRange($firstOperand['start'], $firstOperand['start'], $insertTokens);
16✔
314
    }
315

316
    private function clearConcatAndAround(Tokens $tokens, int $concatOperatorIndex): void
317
    {
318
        if ($tokens[$concatOperatorIndex + 1]->isWhitespace()) {
28✔
319
            $tokens->clearTokenAndMergeSurroundingWhitespace($concatOperatorIndex + 1);
19✔
320
        }
321

322
        $tokens->clearTokenAndMergeSurroundingWhitespace($concatOperatorIndex);
28✔
323

324
        if ($tokens[$concatOperatorIndex - 1]->isWhitespace()) {
28✔
325
            $tokens->clearTokenAndMergeSurroundingWhitespace($concatOperatorIndex - 1);
6✔
326
        }
327
    }
328

329
    private function isSimpleQuotedStringContent(string $candidate): bool
330
    {
331
        return !Preg::match('#[\$"\'\\\]#', substr($candidate, 1, -1));
14✔
332
    }
333

334
    private function containsLinebreak(Tokens $tokens, int $startIndex, int $endIndex): bool
335
    {
336
        for ($i = $endIndex; $i > $startIndex; --$i) {
36✔
337
            if (Preg::match('/\R/', $tokens[$i]->getContent())) {
36✔
338
                return true;
7✔
339
            }
340
        }
341

342
        return false;
34✔
343
    }
344

345
    /**
346
     * @param _ConcatOperandType $firstOperand
347
     * @param _ConcatOperandType $secondOperand
348
     */
349
    private function operandsCanNotBeMerged(Tokens $tokens, array $firstOperand, array $secondOperand): bool
350
    {
351
        // If the first operand does not end with a variable, no variables would be broken by concatenation.
352
        if (self::STR_DOUBLE_QUOTE_VAR !== $firstOperand['type']) {
18✔
353
            return false;
×
354
        }
355
        if (!$tokens[$firstOperand['end'] - 1]->isGivenKind(\T_VARIABLE)) {
18✔
356
            return false;
16✔
357
        }
358

359
        $allowedPatternsForSecondOperand = [
3✔
360
            '/^ .*/', // e.g. " foo", ' bar', " $baz"
3✔
361
            '/^-(?!\>)/', // e.g. "-foo", '-bar', "-$baz"
3✔
362
        ];
3✔
363

364
        // If the first operand ends with a variable, the second operand should match one of the allowed patterns.
365
        // Otherwise, the concatenation can break a variable in the first operand.
366
        foreach ($allowedPatternsForSecondOperand as $allowedPattern) {
3✔
367
            $secondOperandInnerContent = substr($tokens->generatePartialCode($secondOperand['start'], $secondOperand['end']), 1, -1);
3✔
368

369
            if (Preg::match($allowedPattern, $secondOperandInnerContent)) {
3✔
370
                return false;
1✔
371
            }
372
        }
373

374
        return true;
2✔
375
    }
376
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc