Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
SpecificAssertionsSniff
0.00% covered (danger)
0.00%
0 / 106
0.00% covered (danger)
0.00%
0 / 2
1482
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 / 105
0.00% covered (danger)
0.00%
0 / 1
1406
1<?php
2
3namespace MediaWiki\Sniffs\PHPUnit;
4
5use PHP_CodeSniffer\Files\File;
6use PHP_CodeSniffer\Sniffs\Sniff;
7
8/**
9 * Replace generic assertions about specific conditions
10 *   - assertArrayHasKey (and assertArrayNotHasKey)
11 *   - assertContains (and assertNotContains)
12 *   - assertStringContainsString (and assertStringNotContainsString)
13 *   - assertIsArray (and assertIsNotArray)
14 *   - assertEmpty (and assertNotEmpty)
15 *
16 * @author DannyS712
17 * @license GPL-2.0-or-later
18 */
19class SpecificAssertionsSniff implements Sniff {
20    use PHPUnitTestTrait;
21
22    private const ASSERTIONS = [
23        'assertTrue' => [
24            'array_key_exists' => 'assertArrayHasKey',
25            'empty' => 'assertEmpty',
26            'in_array' => 'assertContains',
27            'is_array' => 'assertIsArray',
28        ],
29        'assertFalse' => [
30            'array_key_exists' => 'assertArrayNotHasKey',
31            'empty' => 'assertNotEmpty',
32            'in_array' => 'assertNotContains',
33            'is_array' => 'assertIsNotArray',
34            'strpos' => 'assertStringNotContainsString',
35        ],
36        'assertNotFalse' => [
37            'strpos' => 'assertStringContainsString',
38        ],
39        'assertIsInt' => [
40            'strpos' => 'assertStringContainsString',
41        ],
42    ];
43
44    /**
45     * @inheritDoc
46     */
47    public function register(): array {
48        return [ T_STRING ];
49    }
50
51    /**
52     * @param File $phpcsFile
53     * @param int $stackPtr
54     *
55     * @return void|int
56     */
57    public function process( File $phpcsFile, $stackPtr ) {
58        if ( !$this->isTestFile( $phpcsFile, $stackPtr ) ) {
59            return $phpcsFile->numTokens;
60        }
61
62        $tokens = $phpcsFile->getTokens();
63        $assertion = $tokens[$stackPtr]['content'];
64
65        // We don't care about stuff that's not in a method in a class
66        if ( $tokens[$stackPtr]['level'] < 2 || !isset( self::ASSERTIONS[$assertion] ) ) {
67            return;
68        }
69
70        // now a map of the method name that is within the assertion to the new assertion name
71        $relevantReplacements = self::ASSERTIONS[$assertion];
72
73        $opener = $phpcsFile->findNext( T_WHITESPACE, $stackPtr + 1, null, true );
74        if ( !isset( $tokens[$opener]['parenthesis_closer'] ) ) {
75            // Looks like this string is not a method call
76            return $opener;
77        }
78
79        $method = $phpcsFile->findNext( T_WHITESPACE, $opener + 1, null, true );
80        $functionCalled = $tokens[$method]['content'];
81        // assertEmpty/assertNotEmpty look for empty() which is T_EMPTY
82        if (
83            (
84                $tokens[$method]['code'] !== T_STRING
85                && $tokens[$method]['code'] !== T_EMPTY
86            )
87            || !isset( $relevantReplacements[ $functionCalled ] )
88        ) {
89            return $method;
90        }
91
92        $replacementMethod = $relevantReplacements[ $functionCalled ];
93
94        $methodOpener = $phpcsFile->findNext( T_WHITESPACE, $method + 1, null, true );
95        if ( !isset( $tokens[$methodOpener]['parenthesis_closer'] ) ) {
96            // Looks like this string is not a method call
97            return $methodOpener;
98        }
99
100        $methodCloser = $tokens[$methodOpener]['parenthesis_closer'];
101        $afterMethod = $phpcsFile->findNext( T_WHITESPACE, $methodCloser + 1, null, true );
102        if ( !in_array( $tokens[$afterMethod]['code'], [ T_COMMA, T_CLOSE_PARENTHESIS ] ) ) {
103            // Not followed by a comma and a second parameter, or a closing parenthesis
104            // something more complex is going on
105            return;
106        }
107
108        $methodContentStart = $phpcsFile->findNext( T_WHITESPACE, $methodOpener + 1, null, true );
109        $methodContentEnd = $phpcsFile->findPrevious( T_WHITESPACE, $methodCloser - 1, null, true );
110
111        // Depending on the function, if there is a third parameter we might not be able
112        // to fix it. We need $firstComma later, so declare it outside of the if statement,
113        // and declare $secondComma here too so that they stay together
114        $firstComma = false;
115        $secondComma = false;
116        if ( $functionCalled === 'in_array' || $functionCalled === 'strpos' ) {
117            // Jump over the first two parameters, whatever they may be
118            $searchTokens = [
119                T_OPEN_CURLY_BRACKET,
120                T_OPEN_SQUARE_BRACKET,
121                T_OPEN_PARENTHESIS,
122                T_OPEN_SHORT_ARRAY,
123                T_COMMA
124            ];
125            $next = $phpcsFile->findNext( $searchTokens, $methodOpener + 1, $methodCloser );
126            while ( $secondComma === false ) {
127                if ( $next === false ) {
128                    // No token
129                    break;
130                }
131                switch ( $tokens[$next]['code'] ) {
132                    case T_OPEN_CURLY_BRACKET:
133                    case T_OPEN_SQUARE_BRACKET:
134                    case T_OPEN_PARENTHESIS:
135                    case T_OPEN_SHORT_ARRAY:
136                        if ( isset( $tokens[$next]['parenthesis_closer'] ) ) {
137                            // jump to closing parenthesis to ignore commas between opener and closer
138                            $next = $tokens[$next]['parenthesis_closer'];
139                        } elseif ( isset( $tokens[$next]['bracket_closer'] ) ) {
140                            // jump to closing bracket
141                            $next = $tokens[$next]['bracket_closer'];
142                        }
143                        break;
144                    case T_COMMA:
145                        if ( $firstComma === false ) {
146                            $firstComma = $next;
147                        } else {
148                            $secondComma = $next;
149                        }
150                }
151                $next = $phpcsFile->findNext( $searchTokens, $next + 1, $methodCloser );
152            }
153            if ( $firstComma === false ) {
154                // Huh? Bad function call
155                return;
156            }
157            if ( $secondComma !== false && $functionCalled === 'strpos' ) {
158                // We can't do the replacement if there is a third parameter
159                return;
160            }
161            if ( $secondComma !== false && $functionCalled === 'in_array' ) {
162                // If we wanted to be exact, we would replace in_array with
163                // assertContainsEqual, but since that is less specific than
164                // assertContains, we always use assertContains, even if the in_array
165                // call didn't have a third parameter (true) passed. This *may* result
166                // in the autofix causing tests to fail - if the decision to use in_array
167                // without a third parameter true was intentional, replace the assertContains
168                // with assertContainsEqual manually
169                $next = $phpcsFile->findNext( T_WHITESPACE, $secondComma + 1, $methodCloser, true );
170                if ( $tokens[$next]['code'] === T_FALSE ) {
171                    // false is the default, no need for anything with the assertion
172                    // just need to delete the parameter
173                    $methodContentEnd = $phpcsFile->findPrevious( T_WHITESPACE, $secondComma - 1, null, true );
174                } elseif ( $tokens[$next]['code'] === T_TRUE ) {
175                    // here we would switch from assertContainsEqual to assertContains
176                    // but as noted above we're always using assertContains
177                    $methodContentEnd = $phpcsFile->findPrevious( T_WHITESPACE, $secondComma - 1, null, true );
178                } else {
179                    // third parameter is something else, can't handle
180                    return;
181                }
182                // make sure there is nothing else making things weird
183                $next = $phpcsFile->findNext( T_WHITESPACE, $next + 1, $methodCloser, true );
184                if ( $next ) {
185                    // something like `true || $var` just to mess with us...
186                    return;
187                }
188            }
189        }
190
191        $fix = $phpcsFile->addFixableWarning(
192            '%s should be used instead of manually using %s with the result of %s',
193            $stackPtr,
194            $replacementMethod,
195            [ $replacementMethod, $assertion, $functionCalled ]
196        );
197        if ( !$fix ) {
198            return;
199        }
200
201        $phpcsFile->fixer->beginChangeset();
202
203        // Need to switch the order of parameters from strpos to assertStringContainsString
204        if ( $functionCalled === 'strpos' ) {
205            // strpos( $param1, $param2 )
206            $nonSpaceAfterFirstComma = $phpcsFile->findNext( T_WHITESPACE, $firstComma + 1, null, true );
207            $param1 = $phpcsFile->getTokensAsString(
208                $methodContentStart,
209                $firstComma - $methodContentStart,
210                // keep tabs on multiline statements
211                true
212            );
213            $param2 = $phpcsFile->getTokensAsString(
214                $nonSpaceAfterFirstComma,
215                $methodContentEnd - $nonSpaceAfterFirstComma + 1,
216                // keep tabs on multiline statements
217                true
218            );
219            // Remove the params
220            for ( $i = $methodContentStart; $i <= $methodContentEnd; $i++ ) {
221                if ( $i < $firstComma || $i >= $nonSpaceAfterFirstComma ) {
222                    $phpcsFile->fixer->replaceToken( $i, '' );
223                }
224            }
225            // We got ride of the content before the comma and the content after,
226            // now add the switched content around the comma
227            $phpcsFile->fixer->addContent( $nonSpaceAfterFirstComma, $param1 );
228            $phpcsFile->fixer->addContentBefore( $firstComma, $param2 );
229        }
230
231        $phpcsFile->fixer->replaceToken( $stackPtr, $replacementMethod );
232        $phpcsFile->fixer->replaceToken( $method, '' );
233        $phpcsFile->fixer->replaceToken( $methodOpener, '' );
234        for ( $i = $methodOpener + 1; $i < $methodContentStart; $i++ ) {
235            // Whitespace between function ( and the content
236            $phpcsFile->fixer->replaceToken( $i, '' );
237        }
238        for ( $i = $methodContentEnd + 1; $i < $methodCloser; $i++ ) {
239            // Whitespace between content and ) (could also include the optional third
240            // parameter for in_array)
241            $phpcsFile->fixer->replaceToken( $i, '' );
242        }
243        $phpcsFile->fixer->replaceToken( $methodCloser, '' );
244
245        $phpcsFile->fixer->endChangeset();
246
247        // There is no way the next assertion can be closer than this
248        return $tokens[$opener]['parenthesis_closer'] + 4;
249    }
250
251}