Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 138
0.00% covered (danger)
0.00%
0 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
UnusedUseStatementSniff
0.00% covered (danger)
0.00%
0 / 138
0.00% covered (danger)
0.00%
0 / 9
4032
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 / 59
0.00% covered (danger)
0.00%
0 / 1
702
 extractType
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
72
 findUseStatements
0.00% covered (danger)
0.00%
0 / 25
0.00% covered (danger)
0.00%
0 / 1
56
 findNamespace
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 readNamespace
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
90
 addSameNamespaceWarning
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
6
 markAsUsed
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 removeUseStatement
0.00% covered (danger)
0.00%
0 / 13
0.00% covered (danger)
0.00%
0 / 1
42
1<?php
2/**
3 * Originally from Drupal's coding standard <https://github.com/klausi/coder>
4 *
5 * This program is free software; you can redistribute it and/or modify
6 * it under the terms of the GNU General Public License as published by
7 * the Free Software Foundation; either version 2 of the License, or
8 * (at your option) any later version.
9 *
10 * This program is distributed in the hope that it will be useful,
11 * but WITHOUT ANY WARRANTY; without even the implied warranty of
12 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 * GNU General Public License for more details.
14 *
15 * You should have received a copy of the GNU General Public License along
16 * with this program; if not, write to the Free Software Foundation, Inc.,
17 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
18 * http://www.gnu.org/copyleft/gpl.html
19 *
20 * @file
21 */
22
23namespace MediaWiki\Sniffs\Classes;
24
25use PHP_CodeSniffer\Files\File;
26use PHP_CodeSniffer\Sniffs\Sniff;
27use PHP_CodeSniffer\Util\Tokens;
28
29/**
30 * @author Thiemo Kreuz
31 */
32class UnusedUseStatementSniff implements Sniff {
33
34    /**
35     * Doc tags where a class name is used
36     */
37    private const CLASS_TAGS = [
38        '@param' => null,
39        '@property' => null,
40        '@property-read' => null,
41        '@property-write' => null,
42        '@return' => null,
43        '@see' => null,
44        '@throws' => null,
45        '@var' => null,
46        // phan
47        '@phan-param' => null,
48        '@phan-property' => null,
49        '@phan-return' => null,
50        '@phan-var' => null,
51        // Deprecated
52        '@expectedException' => null,
53        '@method' => null,
54        '@phan-method' => null,
55        '@type' => null,
56    ];
57
58    /**
59     * @inheritDoc
60     */
61    public function register(): array {
62        return [ T_USE ];
63    }
64
65    /**
66     * Processes this test, when one of its tokens is encountered.
67     *
68     * @param File $phpcsFile The file being scanned.
69     * @param int $stackPtr The position of the current token in the stack passed in $tokens.
70     *
71     * @return int|void
72     */
73    public function process( File $phpcsFile, $stackPtr ) {
74        $tokens = $phpcsFile->getTokens();
75
76        // In case this is a `use` of a class (or constant or function) within
77        // a bracketed namespace rather than in the global scope, update the end
78        // accordingly
79        $useScopeEnd = $phpcsFile->numTokens;
80
81        if ( !empty( $tokens[$stackPtr]['conditions'] ) ) {
82            // We only care about use statements in the global scope, or the
83            // equivalent for bracketed namespace (use statements in the namespace
84            // and not in any class, etc.)
85            $scope = array_key_first( $tokens[$stackPtr]['conditions'] );
86            if ( count( $tokens[$stackPtr]['conditions'] ) === 1
87                // @phan-suppress-next-line PhanTypeArraySuspiciousNullable False positive
88                && $tokens[$stackPtr]['conditions'][$scope] === T_NAMESPACE
89            ) {
90                $useScopeEnd = $tokens[$scope]['scope_closer'];
91            } else {
92                return $tokens[$scope]['scope_closer'] ?? $stackPtr;
93            }
94        }
95
96        $afterUseSection = $stackPtr;
97        $shortClassNames = $this->findUseStatements( $phpcsFile, $stackPtr, $afterUseSection );
98        if ( !$shortClassNames ) {
99            return;
100        }
101
102        $classNamesPattern = '{(?<!\\\\)\b('
103            . implode( '|', array_map( 'preg_quote', array_keys( $shortClassNames ) ) )
104            . ')\b}i';
105
106        // Search where the class name is used. PHP treats class names case
107        // insensitive, that's why we cannot search for the exact class name string
108        // and need to iterate over all T_STRING tokens in the file.
109        for ( $i = $afterUseSection; $i < $useScopeEnd; $i++ ) {
110            if ( $tokens[$i]['code'] === T_STRING ) {
111                if ( !isset( $shortClassNames[ strtolower( $tokens[$i]['content'] ) ] ) ) {
112                    continue;
113                }
114
115                // If a backslash is used before the class name then this is some other
116                // use statement.
117                // T_STRING also used for $this->property or self::function() or "function namedFuncton()"
118                $before = $phpcsFile->findPrevious( Tokens::$emptyTokens, $i - 1, null, true );
119                if ( $tokens[$before]['code'] === T_OBJECT_OPERATOR
120                    || $tokens[$before]['code'] === T_NULLSAFE_OBJECT_OPERATOR
121                    || $tokens[$before]['code'] === T_DOUBLE_COLON
122                    || $tokens[$before]['code'] === T_NS_SEPARATOR
123                    || $tokens[$before]['code'] === T_FUNCTION
124                    // Trait use statement within a class.
125                    || ( $tokens[$before]['code'] === T_USE
126                        && empty( $tokens[$before]['conditions'] )
127                    )
128                ) {
129                    continue;
130                }
131
132                $className = $tokens[$i]['content'];
133
134            } elseif ( $tokens[$i]['code'] === T_DOC_COMMENT_TAG ) {
135                // Usage in a doc comment
136                if ( !array_key_exists( $tokens[$i]['content'], self::CLASS_TAGS )
137                    || $tokens[$i + 2]['code'] !== T_DOC_COMMENT_STRING
138                ) {
139                    continue;
140                }
141                $docType = $this->extractType( $tokens[$i + 2]['content'] );
142                if ( !preg_match_all( $classNamesPattern, $docType, $matches ) ) {
143                    continue;
144                }
145                $className = $matches[1];
146
147            } elseif ( $tokens[$i]['code'] === T_CONSTANT_ENCAPSED_STRING ) {
148                if ( $tokens[$i + 1]['code'] !== T_SEMICOLON
149                    || !preg_match( '/^.@phan-var\S*\s+(.*)/i', $tokens[$i]['content'], $matches )
150                ) {
151                    continue;
152                }
153
154                $phanVarType = $this->extractType( $matches[1] );
155                if ( !preg_match_all( $classNamesPattern, $phanVarType, $matches ) ) {
156                    continue;
157                }
158                $className = $matches[1];
159
160            } else {
161                continue;
162            }
163
164            $this->markAsUsed( $shortClassNames, $className );
165            if ( $shortClassNames === [] ) {
166                break;
167            }
168        }
169
170        foreach ( $shortClassNames as [ $i, $shortClassName ] ) {
171            $fix = $phpcsFile->addFixableWarning(
172                'Unused use statement "%s"',
173                $i,
174                'UnusedUse',
175                [ $shortClassName ]
176            );
177            if ( $fix ) {
178                $this->removeUseStatement( $phpcsFile, $i );
179            }
180        }
181
182        return $afterUseSection;
183    }
184
185    /**
186     * Extracts the type from PHPDoc comment strings like "bool[] $var Comment" and
187     * "$var bool[] Comment" (wrong order, but that's for another sniff), while respecting types
188     * like "array<int, array<string, bool>>".
189     *
190     * @param string $str
191     *
192     * @return string
193     */
194    private function extractType( string $str ): string {
195        $start = 0;
196        $brackets = 0;
197        $strLen = strlen( $str );
198        for ( $i = 0; $i < $strLen; $i += strcspn( $str, ' <>', $i + 1 ) + 1 ) {
199            $char = $str[$i];
200            if ( $char === ' ' && !$brackets ) {
201                // If we find the variable name before the type, continue
202                if ( $str[$start] !== '$' ) {
203                    return substr( $str, $start, $i );
204                }
205                $start = $i + 1;
206            } elseif ( $char === '>' && $brackets ) {
207                $brackets--;
208            } elseif ( $char === '<' ) {
209                $brackets++;
210            }
211        }
212        return substr( $str, $start );
213    }
214
215    /**
216     * @param File $phpcsFile
217     * @param int $stackPtr
218     * @param int &$afterUseSection Updated to point to the first token after the found section
219     *
220     * @return int[] Array mapping short, lowercased class names to stack pointers
221     */
222    private function findUseStatements(
223        File $phpcsFile,
224        int $stackPtr,
225        int &$afterUseSection
226    ): array {
227        $tokens = $phpcsFile->getTokens();
228        $currentUsePtr = $stackPtr;
229
230        $namespace = $this->findNamespace( $phpcsFile, $stackPtr );
231        $shortClassNames = [];
232
233        // No need to cache this as we won't execute this often
234        $namespaceTokenTypes = Tokens::$emptyTokens;
235        $namespaceTokenTypes[] = T_NS_SEPARATOR;
236        $namespaceTokenTypes[] = T_STRING;
237        $useTokenTypes = array_merge( $namespaceTokenTypes, [ T_AS ] );
238
239        while ( $currentUsePtr && $tokens[$currentUsePtr]['code'] === T_USE ) {
240            // Seek to the end of the statement and get the string before the semicolon.
241            $semicolon = $phpcsFile->findNext( $useTokenTypes, $currentUsePtr + 1, null, true );
242            if ( $tokens[$semicolon]['code'] !== T_SEMICOLON ) {
243                break;
244            }
245            $afterUseSection = $semicolon + 1;
246
247            // Find the unprefixed class name or "as" alias, if there is one
248            $classNamePtr = $phpcsFile->findPrevious( T_STRING, $semicolon - 1, $currentUsePtr );
249            if ( !$classNamePtr ) {
250                // Live coding
251                break;
252            }
253            $shortClassName = $tokens[$classNamePtr]['content'];
254            $shortClassNames[strtolower( $shortClassName )] = [ $currentUsePtr, $shortClassName ];
255
256            // Check if the referenced class is in the same namespace as the current
257            // file. If it is then the use statement is not necessary.
258            $prev = $phpcsFile->findPrevious( $namespaceTokenTypes, $classNamePtr - 1, null, true );
259            // Check if the use statement does aliasing with the "as" keyword. Aliasing
260            // is allowed even in the same namespace.
261            if ( $tokens[$prev]['code'] !== T_AS ) {
262                $useNamespace = $this->readNamespace( $phpcsFile, $prev + 1, $classNamePtr - 2 );
263                if ( $useNamespace === $namespace ) {
264                    $this->addSameNamespaceWarning( $phpcsFile, $currentUsePtr, $shortClassName );
265                }
266            }
267
268            // This intentionally stops at non-empty tokens for performance reasons, and might miss
269            // later use statements. The sniff will be called another time for these.
270            $currentUsePtr = $phpcsFile->findNext( Tokens::$emptyTokens, $semicolon + 1, null, true );
271        }
272
273        return $shortClassNames;
274    }
275
276    /**
277     * @param File $phpcsFile
278     * @param int $stackPtr
279     *
280     * @return string
281     */
282    private function findNamespace( File $phpcsFile, int $stackPtr ): string {
283        $namespacePtr = $phpcsFile->findPrevious( T_NAMESPACE, $stackPtr - 1 );
284        if ( !$namespacePtr ) {
285            return '';
286        }
287
288        return $this->readNamespace( $phpcsFile, $namespacePtr + 2, $stackPtr - 1 );
289    }
290
291    /**
292     * @param File $phpcsFile
293     * @param int $start
294     * @param int $end
295     *
296     * @return string
297     */
298    private function readNamespace( File $phpcsFile, int $start, int $end ): string {
299        $tokens = $phpcsFile->getTokens();
300        $content = '';
301
302        for ( $i = $start; $i <= $end; $i++ ) {
303            if ( isset( Tokens::$emptyTokens[ $tokens[$i]['code'] ] ) ) {
304                continue;
305            }
306            if ( $tokens[$i]['code'] !== T_STRING && $tokens[$i]['code'] !== T_NS_SEPARATOR ) {
307                break;
308            }
309
310            // This skips leading separators as well as a preceding "const" or "function"
311            if ( $content || ( $tokens[$i]['code'] === T_STRING && (
312                strcasecmp( $tokens[$i]['content'], 'const' ) !== 0 &&
313                strcasecmp( $tokens[$i]['content'], 'function' ) !== 0
314            ) ) ) {
315                $content .= $tokens[$i]['content'];
316            }
317        }
318
319        // Something like "Namespace\ Class" might leave a trailing separator
320        return rtrim( $content, '\\' );
321    }
322
323    /**
324     * @param File $phpcsFile
325     * @param int $stackPtr
326     * @param string $shortClassName
327     */
328    private function addSameNamespaceWarning( File $phpcsFile, int $stackPtr, string $shortClassName ): void {
329        $fix = $phpcsFile->addFixableWarning(
330            'Unnecessary use statement "%s" in the same namespace',
331            $stackPtr,
332            'UnnecessaryUse',
333            [ $shortClassName ]
334        );
335        if ( $fix ) {
336            $this->removeUseStatement( $phpcsFile, $stackPtr );
337        }
338    }
339
340    /**
341     * @param array &$classNames List of class names found in the use section
342     * @param string|string[] $usedClassNames Class name(s) to be marked as used
343     */
344    private function markAsUsed( array &$classNames, $usedClassNames ): void {
345        foreach ( (array)$usedClassNames as $className ) {
346            unset( $classNames[ strtolower( $className ) ] );
347        }
348    }
349
350    /**
351     * @param File $phpcsFile
352     * @param int $stackPtr
353     */
354    private function removeUseStatement( File $phpcsFile, int $stackPtr ): void {
355        $tokens = $phpcsFile->getTokens();
356        // Remove the whole use statement line.
357        $phpcsFile->fixer->beginChangeset();
358
359        // Removing any whitespace before the use statement, for use statements in bracketed
360        // namespaces
361        $i = $phpcsFile->findFirstOnLine( [ T_WHITESPACE ], $stackPtr );
362        if ( !$i ) {
363            // No whitespace beforehand
364            $i = $stackPtr;
365        }
366        do {
367            $phpcsFile->fixer->replaceToken( $i, '' );
368        } while ( $tokens[$i++]['code'] !== T_SEMICOLON && isset( $tokens[$i] ) );
369
370        // Also remove whitespace after the semicolon (new lines).
371        while ( isset( $tokens[$i] )
372            && $tokens[$i]['code'] === T_WHITESPACE
373            && $tokens[$i]['line'] === $tokens[$i - 1]['line']
374        ) {
375            $phpcsFile->fixer->replaceToken( $i, '' );
376            $i++;
377        }
378
379        $phpcsFile->fixer->endChangeset();
380    }
381
382}