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

keradus / PHP-CS-Fixer / 16999983712

15 Aug 2025 09:42PM UTC coverage: 94.75% (-0.09%) from 94.839%
16999983712

push

github

keradus
ci: more self-fixing checks on lowest/highest PHP

28263 of 29829 relevant lines covered (94.75%)

45.88 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
final class NoUselessConcatOperatorFixer extends AbstractFixer implements ConfigurableFixerInterface
46
{
47
    /** @use ConfigurableFixerTrait<_AutogeneratedInputConfiguration, _AutogeneratedComputedConfiguration> */
48
    use ConfigurableFixerTrait;
49

50
    private const STR_DOUBLE_QUOTE = 0;
51
    private const STR_DOUBLE_QUOTE_VAR = 1;
52
    private const STR_SINGLE_QUOTE = 2;
53

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

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

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

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

88
            $nextMeaningfulTokenIndex = $tokens->getNextMeaningfulToken($index);
36✔
89

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

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

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

100
            $prevMeaningfulTokenIndex = $tokens->getPrevMeaningfulToken($index);
34✔
101

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

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

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

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

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

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

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

140
            return;
9✔
141
        }
142

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

148
            $this->mergeConstantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
6✔
149

150
            return;
6✔
151
        }
152

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

155
        $operands = [
20✔
156
            [$firstOperand, $secondOperand],
20✔
157
            [$secondOperand, $firstOperand],
20✔
158
        ];
20✔
159

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

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

168
                $this->mergeConstantEscapedStringVarOperands($tokens, $firstOperand, $concatIndex, $secondOperand);
6✔
169

170
                return;
6✔
171
            }
172

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

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

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

184
                return;
5✔
185
            }
186

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

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

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

198
                return;
7✔
199
            }
200
        }
201
    }
202

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

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

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

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

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

234
        return null;
1✔
235
    }
236

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

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

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

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

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

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

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

286
        // remove tokens making up the concat statement
287

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

293
        $this->clearConcatAndAround($tokens, $concatOperatorIndex);
16✔
294

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

300
        // insert new tokens based on the new content
301

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

305
        $insertTokens = [];
16✔
306

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

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

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

320
        $tokens->clearTokenAndMergeSurroundingWhitespace($concatOperatorIndex);
28✔
321

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

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

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

340
        return false;
34✔
341
    }
342

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

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

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

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

372
        return true;
2✔
373
    }
374
}
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