OrderedImportsFixer::applyFix()   B
last analyzed

Complexity

Conditions 9
Paths 21

Size

Total Lines 46
Code Lines 29

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 29
c 1
b 0
f 0
dl 0
loc 46
rs 8.0555
cc 9
nc 21
nop 2
1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of PHP CS Fixer.
7
 *
8
 * (c) Fabien Potencier <[email protected]>
9
 *     Dariusz Rumiński <[email protected]>
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\Import;
16
17
use PhpCsFixer\AbstractFixer;
18
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
19
use PhpCsFixer\Fixer\WhitespacesAwareFixerInterface;
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\FixerDefinition\VersionSpecification;
27
use PhpCsFixer\FixerDefinition\VersionSpecificCodeSample;
28
use PhpCsFixer\Preg;
29
use PhpCsFixer\Tokenizer\CT;
30
use PhpCsFixer\Tokenizer\Token;
31
use PhpCsFixer\Tokenizer\Tokens;
32
use PhpCsFixer\Tokenizer\TokensAnalyzer;
33
use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
34
35
/**
36
 * @author Sebastiaan Stok <[email protected]>
37
 * @author Dariusz Rumiński <[email protected]>
38
 * @author SpacePossum
39
 * @author Darius Matulionis <[email protected]>
40
 * @author Adriano Pilger <[email protected]>
41
 */
42
final class OrderedImportsFixer extends AbstractFixer implements ConfigurableFixerInterface, WhitespacesAwareFixerInterface
43
{
44
    /**
45
     * @internal
46
     */
47
    public const IMPORT_TYPE_CLASS = 'class';
48
49
    /**
50
     * @internal
51
     */
52
    public const IMPORT_TYPE_CONST = 'const';
53
54
    /**
55
     * @internal
56
     */
57
    public const IMPORT_TYPE_FUNCTION = 'function';
58
59
    /**
60
     * @internal
61
     */
62
    public const SORT_ALPHA = 'alpha';
63
64
    /**
65
     * @internal
66
     */
67
    public const SORT_LENGTH = 'length';
68
69
    /**
70
     * @internal
71
     */
72
    public const SORT_NONE = 'none';
73
74
    /**
75
     * Array of supported sort types in configuration.
76
     *
77
     * @var string[]
78
     */
79
    private const SUPPORTED_SORT_TYPES = [self::IMPORT_TYPE_CLASS, self::IMPORT_TYPE_CONST, self::IMPORT_TYPE_FUNCTION];
80
81
    /**
82
     * Array of supported sort algorithms in configuration.
83
     *
84
     * @var string[]
85
     */
86
    private const SUPPORTED_SORT_ALGORITHMS = [self::SORT_ALPHA, self::SORT_LENGTH, self::SORT_NONE];
87
88
    /**
89
     * {@inheritdoc}
90
     */
91
    public function getDefinition(): FixerDefinitionInterface
92
    {
93
        return new FixerDefinition(
94
            'Ordering `use` statements.',
95
            [
96
                new CodeSample("<?php\nuse Z; use A;\n"),
97
                new CodeSample(
98
                    '<?php
99
use Acme\Bar;
100
use Bar1;
101
use Acme;
102
use Bar;
103
',
104
                    ['sort_algorithm' => self::SORT_LENGTH]
105
                ),
106
                new VersionSpecificCodeSample(
107
                    "<?php\nuse function AAC;\nuse const AAB;\nuse AAA;\n",
108
                    new VersionSpecification(70000)
109
                ),
110
                new VersionSpecificCodeSample(
111
                    '<?php
112
use const AAAA;
113
use const BBB;
114
115
use Bar;
116
use AAC;
117
use Acme;
118
119
use function CCC\AA;
120
use function DDD;
121
',
122
                    new VersionSpecification(70000),
123
                    [
124
                        'sort_algorithm' => self::SORT_LENGTH,
125
                        'imports_order' => [
126
                            self::IMPORT_TYPE_CONST,
127
                            self::IMPORT_TYPE_CLASS,
128
                            self::IMPORT_TYPE_FUNCTION,
129
                        ],
130
                    ]
131
                ),
132
                new VersionSpecificCodeSample(
133
                    '<?php
134
use const BBB;
135
use const AAAA;
136
137
use Acme;
138
use AAC;
139
use Bar;
140
141
use function DDD;
142
use function CCC\AA;
143
',
144
                    new VersionSpecification(70000),
145
                    [
146
                        'sort_algorithm' => self::SORT_ALPHA,
147
                        'imports_order' => [
148
                            self::IMPORT_TYPE_CONST,
149
                            self::IMPORT_TYPE_CLASS,
150
                            self::IMPORT_TYPE_FUNCTION,
151
                        ],
152
                    ]
153
                ),
154
                new VersionSpecificCodeSample(
155
                    '<?php
156
use const BBB;
157
use const AAAA;
158
159
use function DDD;
160
use function CCC\AA;
161
162
use Acme;
163
use AAC;
164
use Bar;
165
',
166
                    new VersionSpecification(70000),
167
                    [
168
                        'sort_algorithm' => self::SORT_NONE,
169
                        'imports_order' => [
170
                            self::IMPORT_TYPE_CONST,
171
                            self::IMPORT_TYPE_CLASS,
172
                            self::IMPORT_TYPE_FUNCTION,
173
                        ],
174
                    ]
175
                ),
176
            ]
177
        );
178
    }
179
180
    /**
181
     * {@inheritdoc}
182
     *
183
     * Must run after GlobalNamespaceImportFixer, NoLeadingImportSlashFixer.
184
     */
185
    public function getPriority(): int
186
    {
187
        return -30;
188
    }
189
190
    /**
191
     * {@inheritdoc}
192
     */
193
    public function isCandidate(Tokens $tokens): bool
194
    {
195
        return $tokens->isTokenKindFound(T_USE);
196
    }
197
198
    /**
199
     * {@inheritdoc}
200
     */
201
    protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
202
    {
203
        $tokensAnalyzer = new TokensAnalyzer($tokens);
204
        $namespacesImports = $tokensAnalyzer->getImportUseIndexes(true);
205
206
        if (0 === \count($namespacesImports)) {
207
            return;
208
        }
209
210
        $usesOrder = [];
211
        foreach ($namespacesImports as $uses) {
212
            $usesOrder[] = $this->getNewOrder(array_reverse($uses), $tokens);
213
        }
214
        $usesOrder = array_replace(...$usesOrder);
215
216
        $usesOrder = array_reverse($usesOrder, true);
217
        $mapStartToEnd = [];
218
219
        foreach ($usesOrder as $use) {
220
            $mapStartToEnd[$use['startIndex']] = $use['endIndex'];
221
        }
222
223
        // Now insert the new tokens, starting from the end
224
        foreach ($usesOrder as $index => $use) {
225
            $declarationTokens = Tokens::fromCode(
226
                sprintf(
227
                    '<?php use %s%s;',
228
                    self::IMPORT_TYPE_CLASS === $use['importType'] ? '' : ' '.$use['importType'].' ',
229
                    $use['namespace']
230
                )
231
            );
232
233
            $declarationTokens->clearRange(0, 2); // clear `<?php use `
234
            $declarationTokens->clearAt(\count($declarationTokens) - 1); // clear `;`
235
            $declarationTokens->clearEmptyTokens();
236
237
            $tokens->overrideRange($index, $mapStartToEnd[$index], $declarationTokens);
238
            if ($use['group']) {
239
                // a group import must start with `use` and cannot be part of comma separated import list
240
                $prev = $tokens->getPrevMeaningfulToken($index);
241
                if ($tokens[$prev]->equals(',')) {
242
                    $tokens[$prev] = new Token(';');
243
                    $tokens->insertAt($prev + 1, new Token([T_USE, 'use']));
244
245
                    if (!$tokens[$prev + 2]->isWhitespace()) {
246
                        $tokens->insertAt($prev + 2, new Token([T_WHITESPACE, ' ']));
247
                    }
248
                }
249
            }
250
        }
251
    }
252
253
    /**
254
     * {@inheritdoc}
255
     */
256
    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
257
    {
258
        $supportedSortTypes = self::SUPPORTED_SORT_TYPES;
259
260
        return new FixerConfigurationResolver([
261
            (new FixerOptionBuilder('sort_algorithm', 'whether the statements should be sorted alphabetically or by length, or not sorted'))
262
                ->setAllowedValues(self::SUPPORTED_SORT_ALGORITHMS)
263
                ->setDefault(self::SORT_ALPHA)
264
                ->getOption(),
265
            (new FixerOptionBuilder('imports_order', 'Defines the order of import types.'))
266
                ->setAllowedTypes(['array', 'null'])
267
                ->setAllowedValues([static function (?array $value) use ($supportedSortTypes) {
268
                    if (null !== $value) {
0 ignored issues
show
introduced by
The condition null !== $value is always true.
Loading history...
269
                        $missing = array_diff($supportedSortTypes, $value);
270
                        if (\count($missing)) {
271
                            throw new InvalidOptionsException(sprintf(
272
                                'Missing sort %s "%s".',
273
                                1 === \count($missing) ? 'type' : 'types',
274
                                implode('", "', $missing)
275
                            ));
276
                        }
277
278
                        $unknown = array_diff($value, $supportedSortTypes);
279
                        if (\count($unknown)) {
280
                            throw new InvalidOptionsException(sprintf(
281
                                'Unknown sort %s "%s".',
282
                                1 === \count($unknown) ? 'type' : 'types',
283
                                implode('", "', $unknown)
284
                            ));
285
                        }
286
                    }
287
288
                    return true;
289
                }])
290
                ->setDefault(null)
291
                ->getOption(),
292
        ]);
293
    }
294
295
    /**
296
     * This method is used for sorting the uses in a namespace.
297
     *
298
     * @param array<string, bool|int|string> $first
299
     * @param array<string, bool|int|string> $second
300
     *
301
     * @internal
302
     */
303
    private function sortAlphabetically(array $first, array $second): int
304
    {
305
        // Replace backslashes by spaces before sorting for correct sort order
306
        $firstNamespace = str_replace('\\', ' ', $this->prepareNamespace($first['namespace']));
0 ignored issues
show
Bug introduced by
It seems like $first['namespace'] can also be of type boolean; however, parameter $namespace of PhpCsFixer\Fixer\Import\...xer::prepareNamespace() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

306
        $firstNamespace = str_replace('\\', ' ', $this->prepareNamespace(/** @scrutinizer ignore-type */ $first['namespace']));
Loading history...
307
        $secondNamespace = str_replace('\\', ' ', $this->prepareNamespace($second['namespace']));
308
309
        return strcasecmp($firstNamespace, $secondNamespace);
310
    }
311
312
    /**
313
     * This method is used for sorting the uses statements in a namespace by length.
314
     *
315
     * @param array<string, bool|int|string> $first
316
     * @param array<string, bool|int|string> $second
317
     *
318
     * @internal
319
     */
320
    private function sortByLength(array $first, array $second): int
321
    {
322
        $firstNamespace = (self::IMPORT_TYPE_CLASS === $first['importType'] ? '' : $first['importType'].' ').$this->prepareNamespace($first['namespace']);
323
        $secondNamespace = (self::IMPORT_TYPE_CLASS === $second['importType'] ? '' : $second['importType'].' ').$this->prepareNamespace($second['namespace']);
324
325
        $firstNamespaceLength = \strlen($firstNamespace);
326
        $secondNamespaceLength = \strlen($secondNamespace);
327
328
        if ($firstNamespaceLength === $secondNamespaceLength) {
329
            $sortResult = strcasecmp($firstNamespace, $secondNamespace);
330
        } else {
331
            $sortResult = $firstNamespaceLength > $secondNamespaceLength ? 1 : -1;
332
        }
333
334
        return $sortResult;
335
    }
336
337
    private function prepareNamespace(string $namespace): string
338
    {
339
        return trim(Preg::replace('%/\*(.*)\*/%s', '', $namespace));
340
    }
341
342
    /**
343
     * @param int[] $uses
344
     */
345
    private function getNewOrder(array $uses, Tokens $tokens): array
346
    {
347
        $indexes = [];
348
        $originalIndexes = [];
349
        $lineEnding = $this->whitespacesConfig->getLineEnding();
350
351
        for ($i = \count($uses) - 1; $i >= 0; --$i) {
352
            $index = $uses[$i];
353
354
            $startIndex = $tokens->getTokenNotOfKindsSibling($index + 1, 1, [T_WHITESPACE]);
355
            $endIndex = $tokens->getNextTokenOfKind($startIndex, [';', [T_CLOSE_TAG]]);
0 ignored issues
show
Bug introduced by
It seems like $startIndex can also be of type null; however, parameter $index of PhpCsFixer\Tokenizer\Tokens::getNextTokenOfKind() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

355
            $endIndex = $tokens->getNextTokenOfKind(/** @scrutinizer ignore-type */ $startIndex, [';', [T_CLOSE_TAG]]);
Loading history...
356
            $previous = $tokens->getPrevMeaningfulToken($endIndex);
0 ignored issues
show
Bug introduced by
It seems like $endIndex can also be of type null; however, parameter $index of PhpCsFixer\Tokenizer\Tok...etPrevMeaningfulToken() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

356
            $previous = $tokens->getPrevMeaningfulToken(/** @scrutinizer ignore-type */ $endIndex);
Loading history...
357
358
            $group = $tokens[$previous]->isGivenKind(CT::T_GROUP_IMPORT_BRACE_CLOSE);
359
            if ($tokens[$startIndex]->isGivenKind(CT::T_CONST_IMPORT)) {
360
                $type = self::IMPORT_TYPE_CONST;
361
                $index = $tokens->getNextNonWhitespace($startIndex);
0 ignored issues
show
Bug introduced by
It seems like $startIndex can also be of type null; however, parameter $index of PhpCsFixer\Tokenizer\Tok...:getNextNonWhitespace() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

361
                $index = $tokens->getNextNonWhitespace(/** @scrutinizer ignore-type */ $startIndex);
Loading history...
362
            } elseif ($tokens[$startIndex]->isGivenKind(CT::T_FUNCTION_IMPORT)) {
363
                $type = self::IMPORT_TYPE_FUNCTION;
364
                $index = $tokens->getNextNonWhitespace($startIndex);
365
            } else {
366
                $type = self::IMPORT_TYPE_CLASS;
367
                $index = $startIndex;
368
            }
369
370
            $namespaceTokens = [];
371
372
            while ($index <= $endIndex) {
373
                $token = $tokens[$index];
374
375
                if ($index === $endIndex || (!$group && $token->equals(','))) {
376
                    if ($group && self::SORT_NONE !== $this->configuration['sort_algorithm']) {
377
                        // if group import, sort the items within the group definition
378
379
                        // figure out where the list of namespace parts within the group def. starts
380
                        $namespaceTokensCount = \count($namespaceTokens) - 1;
381
                        $namespace = '';
382
                        for ($k = 0; $k < $namespaceTokensCount; ++$k) {
383
                            if ($namespaceTokens[$k]->isGivenKind(CT::T_GROUP_IMPORT_BRACE_OPEN)) {
384
                                $namespace .= '{';
385
386
                                break;
387
                            }
388
389
                            $namespace .= $namespaceTokens[$k]->getContent();
390
                        }
391
392
                        // fetch all parts, split up in an array of strings, move comments to the end
393
                        $parts = [];
394
                        $firstIndent = '';
395
                        $separator = ', ';
396
                        $lastIndent = '';
397
                        $hasGroupTrailingComma = false;
398
399
                        for ($k1 = $k + 1; $k1 < $namespaceTokensCount; ++$k1) {
400
                            $comment = '';
401
                            $namespacePart = '';
402
                            for ($k2 = $k1;; ++$k2) {
403
                                if ($namespaceTokens[$k2]->equalsAny([',', [CT::T_GROUP_IMPORT_BRACE_CLOSE]])) {
404
                                    break;
405
                                }
406
407
                                if ($namespaceTokens[$k2]->isComment()) {
408
                                    $comment .= $namespaceTokens[$k2]->getContent();
409
410
                                    continue;
411
                                }
412
413
                                // if there is any line ending inside the group import, it should be indented properly
414
                                if (
415
                                    '' === $firstIndent
416
                                    && $namespaceTokens[$k2]->isWhitespace()
417
                                    && false !== strpos($namespaceTokens[$k2]->getContent(), $lineEnding)
418
                                ) {
419
                                    $lastIndent = $lineEnding;
420
                                    $firstIndent = $lineEnding.$this->whitespacesConfig->getIndent();
421
                                    $separator = ','.$firstIndent;
422
                                }
423
424
                                $namespacePart .= $namespaceTokens[$k2]->getContent();
425
                            }
426
427
                            $namespacePart = trim($namespacePart);
428
                            if ('' === $namespacePart) {
429
                                $hasGroupTrailingComma = true;
430
431
                                continue;
432
                            }
433
434
                            $comment = trim($comment);
435
                            if ('' !== $comment) {
436
                                $namespacePart .= ' '.$comment;
437
                            }
438
439
                            $parts[] = $namespacePart;
440
441
                            $k1 = $k2;
442
                        }
443
444
                        $sortedParts = $parts;
445
                        sort($parts);
446
447
                        // check if the order needs to be updated, otherwise don't touch as we might change valid CS (to other valid CS).
448
                        if ($sortedParts === $parts) {
449
                            $namespace = Tokens::fromArray($namespaceTokens)->generateCode();
450
                        } else {
451
                            $namespace .= $firstIndent.implode($separator, $parts).($hasGroupTrailingComma ? ',' : '').$lastIndent.'}';
452
                        }
453
                    } else {
454
                        $namespace = Tokens::fromArray($namespaceTokens)->generateCode();
455
                    }
456
457
                    $indexes[$startIndex] = [
458
                        'namespace' => $namespace,
459
                        'startIndex' => $startIndex,
460
                        'endIndex' => $index - 1,
461
                        'importType' => $type,
462
                        'group' => $group,
463
                    ];
464
465
                    $originalIndexes[] = $startIndex;
466
467
                    if ($index === $endIndex) {
468
                        break;
469
                    }
470
471
                    $namespaceTokens = [];
472
                    $nextPartIndex = $tokens->getTokenNotOfKindSibling($index, 1, [[','], [T_WHITESPACE]]);
0 ignored issues
show
Bug introduced by
It seems like $index can also be of type null; however, parameter $index of PhpCsFixer\Tokenizer\Tok...TokenNotOfKindSibling() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

472
                    $nextPartIndex = $tokens->getTokenNotOfKindSibling(/** @scrutinizer ignore-type */ $index, 1, [[','], [T_WHITESPACE]]);
Loading history...
473
                    $startIndex = $nextPartIndex;
474
                    $index = $nextPartIndex;
475
476
                    continue;
477
                }
478
479
                $namespaceTokens[] = $token;
480
                ++$index;
481
            }
482
        }
483
484
        // Is sort types provided, sorting by groups and each group by algorithm
485
        if (null !== $this->configuration['imports_order']) {
486
            // Grouping indexes by import type.
487
            $groupedByTypes = [];
488
            foreach ($indexes as $startIndex => $item) {
489
                $groupedByTypes[$item['importType']][$startIndex] = $item;
490
            }
491
492
            // Sorting each group by algorithm.
493
            foreach ($groupedByTypes as $type => $indexes) {
494
                $groupedByTypes[$type] = $this->sortByAlgorithm($indexes);
495
            }
496
497
            // Ordering groups
498
            $sortedGroups = [];
499
            foreach ($this->configuration['imports_order'] as $type) {
500
                if (isset($groupedByTypes[$type]) && !empty($groupedByTypes[$type])) {
501
                    foreach ($groupedByTypes[$type] as $startIndex => $item) {
502
                        $sortedGroups[$startIndex] = $item;
503
                    }
504
                }
505
            }
506
            $indexes = $sortedGroups;
507
        } else {
508
            // Sorting only by algorithm
509
            $indexes = $this->sortByAlgorithm($indexes);
510
        }
511
512
        $index = -1;
513
        $usesOrder = [];
514
515
        // Loop trough the index but use original index order
516
        foreach ($indexes as $v) {
517
            $usesOrder[$originalIndexes[++$index]] = $v;
518
        }
519
520
        return $usesOrder;
521
    }
522
523
    /**
524
     * @param array[] $indexes
525
     */
526
    private function sortByAlgorithm(array $indexes): array
527
    {
528
        if (self::SORT_ALPHA === $this->configuration['sort_algorithm']) {
529
            uasort($indexes, [$this, 'sortAlphabetically']);
530
        } elseif (self::SORT_LENGTH === $this->configuration['sort_algorithm']) {
531
            uasort($indexes, [$this, 'sortByLength']);
532
        }
533
534
        return $indexes;
535
    }
536
}
537