AlphabeticalUseStatementsSniff::getUseImport()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 25
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 15
c 1
b 0
f 0
nc 2
nop 2
dl 0
loc 25
rs 9.7666
1
<?php
2
3
/**
4
 * This file is part of the mo4-coding-standard (phpcs standard)
5
 *
6
 * @author  Xaver Loppenstedt <[email protected]>
7
 *
8
 * @license http://spdx.org/licenses/MIT MIT License
9
 *
10
 * @link    https://github.com/mayflower/mo4-coding-standard
11
 */
12
13
declare(strict_types=1);
14
15
namespace MO4\Sniffs\Formatting;
16
17
use PHP_CodeSniffer\Files\File;
18
use PHP_CodeSniffer\Standards\PSR2\Sniffs\Namespaces\UseDeclarationSniff;
19
use PHP_CodeSniffer\Util\Common;
20
use PHP_CodeSniffer\Util\Tokens as PHP_CodeSniffer_Tokens;
21
22
/**
23
 * Alphabetical Use Statements sniff.
24
 *
25
 * Use statements must be in alphabetical order, grouped by empty lines.
26
 *
27
 * @author    Xaver Loppenstedt <[email protected]>
28
 * @author    Steffen Ritter <[email protected]>
29
 * @author    Christian Albrecht <[email protected]>
30
 *
31
 * @copyright 2013-2017 Xaver Loppenstedt, some rights reserved.
32
 *
33
 * @license   http://spdx.org/licenses/MIT MIT License
34
 *
35
 * @link      https://github.com/mayflower/mo4-coding-standard
36
 */
37
class AlphabeticalUseStatementsSniff extends UseDeclarationSniff
38
{
39
    private const NAMESPACE_SEPARATOR_STRING = '\\';
40
41
    private const SUPPORTED_ORDERING_METHODS = [
42
        'dictionary',
43
        'string',
44
        'string',
45
        'string-locale',
46
        'string-case-insensitive',
47
    ];
48
49
    /**
50
     * Sorting order, see SUPPORTED_ORDERING_METHODS for possible settings
51
     *
52
     * Unknown types will be mapped to 'string'.
53
     *
54
     * @var string
55
     */
56
    public $order = 'dictionary';
57
58
    /**
59
     * Last import seen in group
60
     *
61
     * @var string
62
     */
63
    private $lastImport = '';
64
65
    /**
66
     * Line number of the last seen use statement
67
     *
68
     * @var int
69
     */
70
    private $lastLine = -1;
71
72
    /**
73
     * Current file
74
     *
75
     * @var string
76
     */
77
    private $currentFile = '';
78
79
    /**
80
     * Processes this test, when one of its tokens is encountered.
81
     *
82
     * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint
83
     *
84
     * @param File $phpcsFile The file being scanned.
85
     * @param int  $stackPtr  The position of the current token in
86
     *                        the stack passed in $tokens.
87
     *
88
     * @return void
89
     */
90
    public function process(File $phpcsFile, $stackPtr): void
91
    {
92
        if (false === \in_array($this->order, self::SUPPORTED_ORDERING_METHODS, true)) {
93
            $error = \sprintf(
94
                "'%s' is not a valid order function for %s! Pick one of: %s",
95
                $this->order,
96
                Common::getSniffCode(self::class),
97
                \implode(', ', self::SUPPORTED_ORDERING_METHODS)
98
            );
99
100
            $phpcsFile->addError($error, $stackPtr, 'InvalidOrder');
101
102
            return;
103
        }
104
105
        parent::process($phpcsFile, $stackPtr);
106
107
        if ($this->currentFile !== $phpcsFile->getFilename()) {
108
            $this->lastLine    = -1;
109
            $this->lastImport  = '';
110
            $this->currentFile = $phpcsFile->getFilename();
111
        }
112
113
        $tokens = $phpcsFile->getTokens();
114
        $line   = $tokens[$stackPtr]['line'];
115
116
        // Ignore function () use () {...}.
117
        $isNonImportUse = $this->checkIsNonImportUse($phpcsFile, $stackPtr);
118
119
        if (true === $isNonImportUse) {
120
            return;
121
        }
122
123
        $currentImportArr = $this->getUseImport($phpcsFile, $stackPtr);
124
125
        if (false === $currentImportArr) {
126
            return;
127
        }
128
129
        $currentPtr    = $currentImportArr['startPtr'];
130
        $currentImport = $currentImportArr['content'];
131
132
        if (($this->lastLine + 1) < $line) {
133
            $this->lastLine   = $line;
134
            $this->lastImport = $currentImport;
135
136
            return;
137
        }
138
139
        $fixable = false;
140
141
        if ('' !== $this->lastImport
142
            && $this->compareString($this->lastImport, $currentImport) > 0
143
        ) {
144
            $msg     = 'USE statements must be sorted alphabetically, order %s';
145
            $code    = 'MustBeSortedAlphabetically';
146
            $fixable = $phpcsFile->addFixableError($msg, $currentPtr, $code, [$this->order]);
147
        }
148
149
        if (true === $fixable) {
150
            // Find the correct position in current use block.
151
            $newDestinationPtr
152
                = $this->findNewDestination($phpcsFile, $stackPtr, $currentImport);
153
154
            $currentUseStr = $this->getUseStatementAsString($phpcsFile, $stackPtr);
155
156
            $phpcsFile->fixer->beginChangeset();
157
            $phpcsFile->fixer->addContentBefore($newDestinationPtr, $currentUseStr);
158
            $this->fixerClearLine($phpcsFile, $stackPtr);
159
            $phpcsFile->fixer->endChangeset();
160
        }
161
162
        $this->lastImport = $currentImport;
163
        $this->lastLine   = $line;
164
    }
165
166
    /**
167
     * Get the import class name for use statement pointed by $stackPtr.
168
     *
169
     * @param File $phpcsFile PHP CS File
170
     * @param int  $stackPtr  pointer
171
     *
172
     * @return array|false
173
     */
174
    private function getUseImport(File $phpcsFile, int $stackPtr)
175
    {
176
        $importTokens = [
177
            T_NS_SEPARATOR,
178
            T_STRING,
179
        ];
180
181
        $start = $phpcsFile->findNext(
182
            PHP_CodeSniffer_Tokens::$emptyTokens,
183
            ($stackPtr + 1),
184
            null,
185
            true
186
        );
187
188
        // $start is false when "use" is the last token in file...
189
        if (false === $start) {
190
            return false;
191
        }
192
193
        $end    = (int) $phpcsFile->findNext($importTokens, $start, null, true);
194
        $import = $phpcsFile->getTokensAsString($start, ($end - $start));
195
196
        return [
197
            'startPtr' => $start,
198
            'content'  => $import,
199
        ];
200
    }
201
202
    /**
203
     * Get the full use statement as string, including trailing white space.
204
     *
205
     * @param File $phpcsFile PHP CS File
206
     * @param int  $stackPtr  pointer
207
     *
208
     * @return string
209
     */
210
    private function getUseStatementAsString(File $phpcsFile, int $stackPtr): string
211
    {
212
        $tokens = $phpcsFile->getTokens();
213
214
        $useEndPtr = (int) $phpcsFile->findNext([T_SEMICOLON], ($stackPtr + 2));
215
        $useLength = ($useEndPtr - $stackPtr + 1);
216
217
        if (T_WHITESPACE === $tokens[($useEndPtr + 1)]['code']) {
218
            $useLength++;
219
        }
220
221
        return $phpcsFile->getTokensAsString($stackPtr, $useLength);
222
    }
223
224
    /**
225
     * Check if "use" token is not used for import.
226
     * E.g. function () use () {...}.
227
     *
228
     * @param File $phpcsFile PHP CS File
229
     * @param int  $stackPtr  pointer
230
     *
231
     * @return bool
232
     */
233
    private function checkIsNonImportUse(File $phpcsFile, int $stackPtr): bool
234
    {
235
        $tokens = $phpcsFile->getTokens();
236
237
        $prev = $phpcsFile->findPrevious(
238
            PHP_CodeSniffer_Tokens::$emptyTokens,
239
            ($stackPtr - 1),
240
            0,
241
            true,
242
            null,
243
            true
244
        );
245
246
        if (false !== $prev) {
247
            $prevToken = $tokens[$prev];
248
249
            if (T_CLOSE_PARENTHESIS === $prevToken['code']) {
250
                return true;
251
            }
252
        }
253
254
        return false;
255
    }
256
257
    /**
258
     * Replace all the token in same line as the element pointed to by $stackPtr
259
     * the by the empty string.
260
     * This will delete the line.
261
     *
262
     * @param File $phpcsFile PHP CS file
263
     * @param int  $stackPtr  pointer
264
     *
265
     * @return void
266
     */
267
    private function fixerClearLine(File $phpcsFile, int $stackPtr): void
268
    {
269
        $tokens = $phpcsFile->getTokens();
270
        $line   = $tokens[$stackPtr]['line'];
271
272
        for ($i = ($stackPtr - 1); $tokens[$i]['line'] === $line; $i--) {
273
            $phpcsFile->fixer->replaceToken($i, '');
274
        }
275
276
        for ($i = $stackPtr; $tokens[$i]['line'] === $line; $i++) {
277
            $phpcsFile->fixer->replaceToken($i, '');
278
        }
279
    }
280
281
    /**
282
     * Find a new destination pointer for the given import string in current
283
     * use block.
284
     *
285
     * @param File   $phpcsFile PHP CS File
286
     * @param int    $stackPtr  pointer
287
     * @param string $import    import string requiring new position
288
     *
289
     * @return int
290
     */
291
    private function findNewDestination(File $phpcsFile, int $stackPtr, string $import): int
292
    {
293
        $tokens = $phpcsFile->getTokens();
294
295
        $line     = $tokens[$stackPtr]['line'];
296
        $prevLine = false;
297
        $prevPtr  = $stackPtr;
298
299
        do {
300
            $ptr = $prevPtr;
301
302
            // Use $line for the first iteration.
303
            if (false !== $prevLine) {
304
                $line = $prevLine;
305
            }
306
307
            $prevPtr = $phpcsFile->findPrevious(T_USE, ($ptr - 1));
308
309
            if (false === $prevPtr) {
310
                break;
311
            }
312
313
            $prevLine = $tokens[$prevPtr]['line'];
314
            // phpcs:disable
315
            /** @var array<string> $prevImportArr */
316
            $prevImportArr = $this->getUseImport($phpcsFile, $prevPtr);
317
            // phpcs:enable
318
        } while ($prevLine === ($line - 1)
319
            && ($this->compareString($prevImportArr['content'], $import) > 0)
320
        );
321
322
        return $ptr;
323
    }
324
325
    /**
326
     * Compare namespace strings according defined order function.
327
     *
328
     * @param string $a first namespace string
329
     * @param string $b second namespace string
330
     *
331
     * @return int
332
     */
333
    private function compareString(string $a, string $b): int
334
    {
335
        switch ($this->order) {
336
            case 'string':
337
                return \strcmp($a, $b);
338
            case 'string-locale':
339
                return \strcoll($a, $b);
340
            case 'string-case-insensitive':
341
                return \strcasecmp($a, $b);
342
            default:
343
                // Default is 'dictionary'.
344
                return $this->dictionaryCompare($a, $b);
345
        }
346
    }
347
348
    /**
349
     * Lexicographical namespace string compare.
350
     *
351
     * Example:
352
     *
353
     *   use Doctrine\ORM\Query;
354
     *   use Doctrine\ORM\Query\Expr;
355
     *   use Doctrine\ORM\QueryBuilder;
356
     *
357
     * @param string $a first namespace string
358
     * @param string $b second namespace string
359
     *
360
     * @return int
361
     */
362
    private function dictionaryCompare(string $a, string $b): int
363
    {
364
        $min = \min(\strlen($a), \strlen($b));
365
366
        for ($i = 0; $i < $min; $i++) {
367
            if ($a[$i] === $b[$i]) {
368
                continue;
369
            }
370
371
            if (self::NAMESPACE_SEPARATOR_STRING === $a[$i]) {
372
                return -1;
373
            }
374
375
            if (self::NAMESPACE_SEPARATOR_STRING === $b[$i]) {
376
                return 1;
377
            }
378
379
            if ($a[$i] < $b[$i]) {
380
                return -1;
381
            }
382
383
            if ($a[$i] > $b[$i]) {
384
                return 1;
385
            }
386
        }
387
388
        return \strcmp(\substr($a, $min), \substr($b, $min));
389
    }
390
}
391