Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 97
0.00% covered (danger)
0.00%
0 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
UnsortedUseStatementsSniff
0.00% covered (danger)
0.00%
0 / 97
0.00% covered (danger)
0.00%
0 / 4
1260
0.00% covered (danger)
0.00%
0 / 1
 register
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 process
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
306
 makeUseStatementList
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
240
 sortByFullQualifiedClassName
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2/**
3 * This program is free software; you can redistribute it and/or modify
4 * it under the terms of the GNU General Public License as published by
5 * the Free Software Foundation; either version 2 of the License, or
6 * (at your option) any later version.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11 * GNU General Public License for more details.
12 *
13 * You should have received a copy of the GNU General Public License along
14 * with this program; if not, write to the Free Software Foundation, Inc.,
15 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
16 * http://www.gnu.org/copyleft/gpl.html
17 *
18 * @file
19 */
20
21namespace MediaWiki\Sniffs\Classes;
22
23use PHP_CodeSniffer\Files\File;
24use PHP_CodeSniffer\Sniffs\Sniff;
25use PHP_CodeSniffer\Util\Tokens;
26
27class UnsortedUseStatementsSniff implements Sniff {
28
29    // Preferred order is classes → functions → constants
30    private const ORDER = [ 'function' => 1, 'const' => 2 ];
31
32    /**
33     * @inheritDoc
34     */
35    public function register(): array {
36        return [ T_USE ];
37    }
38
39    /**
40     * @inheritDoc
41     *
42     * @param File $phpcsFile
43     * @param int $stackPtr
44     * @return int|void
45     */
46    public function process( File $phpcsFile, $stackPtr ) {
47        $tokens = $phpcsFile->getTokens();
48
49        // In case this is a `use` of a class (or constant or function) within
50        // a bracketed namespace rather than in the global scope, update the end
51        // accordingly
52        $useScopeEnd = $phpcsFile->numTokens;
53
54        if ( !empty( $tokens[$stackPtr]['conditions'] ) ) {
55            // We only care about use statements in the global scope, or the
56            // equivalent for bracketed namespace (use statements in the namespace
57            // and not in any class, etc.)
58            $scope = array_key_first( $tokens[$stackPtr]['conditions'] );
59            if ( count( $tokens[$stackPtr]['conditions'] ) === 1
60                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
61                && $tokens[$stackPtr]['conditions'][$scope] === T_NAMESPACE
62            ) {
63                $useScopeEnd = $tokens[$scope]['scope_closer'];
64            } else {
65                return $tokens[$scope]['scope_closer'] ?? $stackPtr;
66            }
67        }
68
69        $useStatementList = $this->makeUseStatementList( $phpcsFile, $stackPtr, $useScopeEnd );
70        // Nothing to do, bail out as fast as possible
71        if ( count( $useStatementList ) <= 1 ) {
72            return;
73        }
74
75        $sortedStatements = $this->sortByFullQualifiedClassName( $useStatementList );
76        // Continue *after* the last use token to not process it twice
77        $afterLastUse = end( $useStatementList )['end'] + 1;
78
79        if ( $useStatementList === $sortedStatements ) {
80            return $afterLastUse;
81        }
82
83        $fix = $phpcsFile->addFixableWarning(
84            'Use statements are not alphabetically sorted',
85            $stackPtr,
86            'UnsortedUse'
87        );
88        if ( !$fix ) {
89            return $afterLastUse;
90        }
91
92        // Find how much whitespace there was before the T_USE for the first
93        // use statement, so that we can indent the rest the same amount,
94        // used for bracketed namespaces
95        $leadingWhitespace = '';
96        $firstOnLine = $phpcsFile->findFirstOnLine(
97            [ T_WHITESPACE ],
98            $stackPtr
99        );
100
101        $phpcsFile->fixer->beginChangeset();
102
103        // If there was no leading whitespace, we will be inserting the
104        // sorted list in the location of the first T_USE, but if there
105        // was whitespace, insert at the start of that line
106        $insertPoint = $stackPtr;
107        if ( $firstOnLine ) {
108            $insertPoint = $firstOnLine;
109            for ( $iii = $firstOnLine; $iii < $stackPtr; $iii++ ) {
110                if ( $tokens[$iii]['code'] === T_WHITESPACE ) {
111                    // Handle tabs converted to spaces automatically
112                    // by using orig_content if available
113                    $leadingWhitespace .= $tokens[$iii]['orig_content'] ?? $tokens[$iii]['content'];
114                    // Remove the whitespace from before the first
115                    // use statement, will be added back when the
116                    // statement is rewritten
117                    $phpcsFile->fixer->replaceToken( $iii, '' );
118                }
119            }
120        }
121
122        foreach ( $useStatementList as $statement ) {
123            // Remove any whitespace before the start, so that it
124            // doesn't add up for use statements in a bracketed namespace
125            $thisLine = $tokens[ $statement['start'] ]['line'];
126            $i = $statement['start'] - 1;
127            while ( $tokens[$i]['line'] === $thisLine &&
128                $tokens[$i]['code'] === T_WHITESPACE
129            ) {
130                $phpcsFile->fixer->replaceToken( $i, '' );
131                $i--;
132            }
133            for ( $i = $statement['start']; $i <= $statement['end']; $i++ ) {
134                $phpcsFile->fixer->replaceToken( $i, '' );
135            }
136            // Also remove the newline at the end of the line, if there is one
137            if ( $tokens[$i]['code'] === T_WHITESPACE
138                && $tokens[$i]['line'] < $tokens[$i + 1]['line']
139            ) {
140                $phpcsFile->fixer->replaceToken( $i, '' );
141            }
142        }
143
144        foreach ( $sortedStatements as $statement ) {
145            $phpcsFile->fixer->addContent( $insertPoint, $leadingWhitespace );
146            $phpcsFile->fixer->addContent( $insertPoint, $statement['originalContent'] );
147            $phpcsFile->fixer->addNewline( $insertPoint );
148        }
149
150        $phpcsFile->fixer->endChangeset();
151
152        return $afterLastUse;
153    }
154
155    /**
156     * @param File $phpcsFile
157     * @param int $stackPtr
158     * @param int $useScopeEnd
159     * @return array[]
160     */
161    private function makeUseStatementList( File $phpcsFile, int $stackPtr, int $useScopeEnd ): array {
162        $tokens = $phpcsFile->getTokens();
163        $next = $stackPtr;
164        $list = [];
165
166        do {
167            $originalContent = '';
168            $group = 0;
169            $sortKey = '';
170            $collectSortKey = false;
171            $start = $next;
172
173            // The end condition here is for when a file ends directly after a "use"
174            // in the case of statements in the global scope, or to properly limit the
175            // search in case of a bracketed namespace
176            for ( ; $next < $useScopeEnd; $next++ ) {
177                [ 'code' => $code, 'content' => $content ] = $tokens[$next];
178                $originalContent .= $content;
179
180                if ( $code === T_STRING ) {
181                    // Reserved keywords "function" and "const" can not appear anywhere else
182                    if ( strcasecmp( $content, 'function' ) === 0
183                        || strcasecmp( $content, 'const' ) === 0
184                    ) {
185                        $group = self::ORDER[ strtolower( $content ) ];
186                    } elseif ( !$sortKey ) {
187                        // The first non-reserved string is where the class name starts
188                        $collectSortKey = true;
189                    }
190                } elseif ( $code === T_AS ) {
191                    // The string after an "as" is not part of the class name any more
192                    $collectSortKey = false;
193                } elseif ( $code === T_SEMICOLON && $sortKey ) {
194                    $list[] = [
195                        'start' => $start,
196                        'end' => $next,
197                        'originalContent' => $originalContent,
198                        'group' => $group,
199                        // No need to trim(), no spaces or leading backslashes have been collected
200                        'sortKey' => strtolower( $sortKey ),
201                    ];
202
203                    // Try to find the next "use" token after the current one
204                    // We don't care about the end of the findNext since that is enforced
205                    // via the for condition
206                    $next = $phpcsFile->findNext( Tokens::$emptyTokens, $next + 1, null, true );
207                    break;
208                } elseif ( isset( Tokens::$emptyTokens[$code] ) ) {
209                    // We never want any space or comment in the sort key
210                    continue;
211                } elseif ( $code !== T_USE && $code !== T_NS_SEPARATOR ) {
212                    // Unexpected token, stop searching for more "use" keywords
213                    break 2;
214                }
215
216                if ( $collectSortKey ) {
217                    $sortKey .= $content;
218                }
219            }
220        } while ( $next && isset( $tokens[$next] ) && $tokens[$next]['code'] === T_USE );
221
222        return $list;
223    }
224
225    /**
226     * @param array[] $list
227     * @return array[]
228     */
229    private function sortByFullQualifiedClassName( array $list ): array {
230        usort( $list, static function ( array $a, array $b ) {
231            if ( $a['group'] !== $b['group'] ) {
232                return $a['group'] <=> $b['group'];
233            }
234            // Can't use strnatcasecmp() because it behaves different, compared to e.g. PHPStorm
235            return strnatcmp( $a['sortKey'], $b['sortKey'] );
236        } );
237
238        return $list;
239    }
240
241}