Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 112
0.00% covered (danger)
0.00%
0 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
AlphabeticArraySortSniff
0.00% covered (danger)
0.00%
0 / 112
0.00% covered (danger)
0.00%
0 / 6
650
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 / 6
0.00% covered (danger)
0.00%
0 / 1
12
 processDocTag
0.00% covered (danger)
0.00%
0 / 63
0.00% covered (danger)
0.00%
0 / 1
156
 warnOnFirstMismatch
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
12
 rebuildSortedArray
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 sortStatements
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
6
1<?php
2
3/**
4 * Check if the keys of an array are sorted and autofix it.
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 2 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License along
17 * with this program; if not, write to the Free Software Foundation, Inc.,
18 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19 * http://www.gnu.org/copyleft/gpl.html
20 *
21 * @file
22 */
23
24namespace MediaWiki\Sniffs\Arrays;
25
26use ArrayIterator;
27use PHP_CodeSniffer\Files\File;
28use PHP_CodeSniffer\Sniffs\Sniff;
29use PHP_CodeSniffer\Util\Tokens;
30
31class AlphabeticArraySortSniff implements Sniff {
32
33    private const ANNOTATION_NAME = '@phpcs-require-sorted-array';
34
35    /**
36     * @inheritDoc
37     */
38    public function register(): array {
39        return [ T_DOC_COMMENT_OPEN_TAG ];
40    }
41
42    /**
43     * @param File $phpcsFile
44     * @param int $stackPtr The current token index.
45     * @return void
46     */
47    public function process( File $phpcsFile, $stackPtr ) {
48        $tokens = $phpcsFile->getTokens();
49        $end = $tokens[$stackPtr]['comment_closer'];
50        foreach ( $tokens[$stackPtr]['comment_tags'] as $tag ) {
51            if ( $tokens[$tag]['content'] === self::ANNOTATION_NAME ) {
52                $this->processDocTag( $phpcsFile, $tokens, $tag, $end );
53                break;
54            }
55        }
56    }
57
58    /**
59     * @param File $phpcsFile
60     * @param array[] $tokens
61     * @param int $tagPtr Token position of the tag
62     * @param int $docEnd Token position of the end of the doc comment
63     */
64    private function processDocTag( File $phpcsFile, array $tokens, int $tagPtr, int $docEnd ): void {
65        $arrayToken = $phpcsFile->findNext( [ T_OPEN_SHORT_ARRAY, T_ARRAY ], $docEnd + 1 );
66        if ( $arrayToken === false || (
67            // On the same line or one line after the doc block
68            $tokens[$docEnd]['line'] !== $tokens[$arrayToken]['line'] &&
69            $tokens[$docEnd]['line'] !== $tokens[$arrayToken]['line'] - 1 )
70        ) {
71            $phpcsFile->addWarning(
72                'No array found after %s',
73                $tagPtr,
74                'Unsupported',
75                [ self::ANNOTATION_NAME, $tokens[$arrayToken]['content'] ]
76            );
77            return;
78        }
79
80        if ( !isset( $tokens[$arrayToken]['bracket_opener'] ) ) {
81            // Live coding
82            return;
83        }
84
85        $endArray = $tokens[$arrayToken]['bracket_closer'] - 1;
86        $startArray = $phpcsFile->findNext(
87            Tokens::$emptyTokens,
88            $tokens[$arrayToken]['bracket_opener'] + 1,
89            $endArray,
90            true
91        );
92        if ( $startArray === false ) {
93            // Empty array
94            return;
95        }
96        $endArray = $phpcsFile->findPrevious( Tokens::$emptyTokens, $endArray, $startArray, true );
97        if ( $tokens[$endArray]['code'] === T_COMMA ) {
98            // Ignore trailing commas
99            $endArray--;
100        }
101
102        $keys = [];
103        $duplicateCounter = 0;
104        $next = $startArray;
105        while ( $next <= $endArray ) {
106            $endStatement = $phpcsFile->findEndOfStatement( $next, [ T_DOUBLE_ARROW ] );
107            if ( $endStatement >= $endArray ) {
108                // Not going ahead on our own end
109                $endStatement = $endArray;
110                $endItem = $endArray;
111            } else {
112                // Do not track comma
113                $endItem = $endStatement - 1;
114            }
115            $keyToken = $phpcsFile->findNext( Tokens::$emptyTokens, $next, $endItem + 1, true );
116
117            $arrayKey = $tokens[$keyToken]['content'];
118            if ( isset( $keys[$arrayKey] ) ) {
119                $phpcsFile->addWarning(
120                    'Found duplicate key "%s" on array required sorting',
121                    $keyToken,
122                    'Duplicate',
123                    [ $arrayKey ]
124                );
125                $duplicateCounter++;
126                // Make the key unique to get a stable sort result and to handle this token as well
127                $arrayKey .= "\0" . $duplicateCounter;
128            }
129
130            $keys[$arrayKey] = [
131                'key' => $keyToken,
132                'end' => $endItem,
133                'startLocation' => $next,
134                'endLocation' => $endStatement,
135            ];
136            $next = $endStatement + 1;
137        }
138
139        $sortedKeys = $this->sortStatements( $keys );
140        if ( $sortedKeys === array_keys( $keys ) ) {
141            return;
142        }
143
144        $fix = $phpcsFile->addFixableWarning(
145            'Array is not sorted alphabetically',
146            $tagPtr,
147            'Unsorted'
148        );
149
150        if ( $fix ) {
151            $this->rebuildSortedArray( $phpcsFile, $sortedKeys, $keys, $startArray );
152        } else {
153            $this->warnOnFirstMismatch( $phpcsFile, $sortedKeys, $keys );
154        }
155    }
156
157    /**
158     * Add a warning on first mismatched key to make it easier found the wrong key in the array.
159     * On each key could make warning on all keys, when the first is already out of order
160     *
161     * @param File $phpcsFile
162     * @param string[] $sorted
163     * @param array[] $unsorted
164     */
165    private function warnOnFirstMismatch( File $phpcsFile, array $sorted, array $unsorted ): void {
166        $iteratorUnsorted = new ArrayIterator( $unsorted );
167        foreach ( $sorted as $sortedKey ) {
168            $unsortedKey = $iteratorUnsorted->key();
169            if ( $sortedKey !== $unsortedKey ) {
170                $unsortedToken = $iteratorUnsorted->current();
171                $phpcsFile->addFixableWarning(
172                    'This key is out of order (Needs %s, got %s)',
173                    $unsortedToken['key'],
174                    'UnsortedHint',
175                    [ $sortedKey, $unsortedKey ]
176                );
177                break;
178            }
179            $iteratorUnsorted->next();
180        }
181    }
182
183    /**
184     * When autofix is wanted, rebuild the content of the array and use it
185     * Get the comma and line indents between each items from the current order.
186     * Add the key and values in sorted order.
187     *
188     * @param File $phpcsFile
189     * @param string[] $sorted
190     * @param array[] $unsorted
191     * @param int $stackPtr
192     */
193    private function rebuildSortedArray( File $phpcsFile, array $sorted, array $unsorted, int $stackPtr ): void {
194        $phpcsFile->fixer->beginChangeset();
195        $iteratorSorted = new ArrayIterator( $sorted );
196        $newArray = '';
197        $lastEnd = false;
198        foreach ( $unsorted as $values ) {
199            // Add comma and indent between the items
200            if ( $lastEnd !== false ) {
201                $newArray .= $phpcsFile->getTokensAsString(
202                    $lastEnd + 1,
203                    $values['key'] - $lastEnd - 1,
204                    // keep tabs on multiline statements
205                    true
206                );
207            }
208            $lastEnd = $values['end'];
209
210            // Add the array item
211            $sortedKey = $iteratorSorted->current();
212            $unsortedToken = $unsorted[$sortedKey];
213            $newArray .= $phpcsFile->getTokensAsString(
214                $unsortedToken['key'],
215                $unsortedToken['end'] - $unsortedToken['key'] + 1,
216                // keep tabs on multiline statements
217                true
218            );
219            $iteratorSorted->next();
220
221            // remove at old location including comma and indent
222            for ( $i = $unsortedToken['startLocation']; $i <= $unsortedToken['endLocation']; $i++ ) {
223                $phpcsFile->fixer->replaceToken( $i, '' );
224            }
225        }
226        $phpcsFile->fixer->addContent( $stackPtr, $newArray );
227        $phpcsFile->fixer->endChangeset();
228    }
229
230    /**
231     * This sorts the array keys
232     *
233     * @param array[] $statementList Array mapping keys to tokens
234     * @return string[] Sorted list of keys
235     */
236    private function sortStatements( array $statementList ): array {
237        $map = [];
238        foreach ( $statementList as $key => $_ ) {
239            $map[$key] = trim( $key, "'\"" );
240        }
241        natcasesort( $map );
242        // @phan-suppress-next-line PhanTypeMismatchReturn False positive as array_keys can return list<string>
243        return array_keys( $map );
244    }
245
246}