Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 149
0.00% covered (danger)
0.00%
0 / 3
CRAP
0.00% covered (danger)
0.00%
0 / 1
AssertionOrderSniff
0.00% covered (danger)
0.00%
0 / 149
0.00% covered (danger)
0.00%
0 / 3
2162
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 / 55
0.00% covered (danger)
0.00%
0 / 1
90
 getFixInfo
0.00% covered (danger)
0.00%
0 / 93
0.00% covered (danger)
0.00%
0 / 1
1332
1<?php
2
3/**
4 * This program is free software; you can redistribute it and/or modify
5 * it under the terms of the GNU General Public License as published by
6 * the Free Software Foundation; either version 2 of the License, or
7 * (at your option) any later version.
8 *
9 * This program is distributed in the hope that it will be useful,
10 * but WITHOUT ANY WARRANTY; without even the implied warranty of
11 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12 * GNU General Public License for more details.
13 *
14 * You should have received a copy of the GNU General Public License along
15 * with this program; if not, write to the Free Software Foundation, Inc.,
16 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17 * http://www.gnu.org/copyleft/gpl.html
18 *
19 * @file
20 */
21
22namespace MediaWiki\Sniffs\PHPUnit;
23
24use PHP_CodeSniffer\Files\File;
25use PHP_CodeSniffer\Sniffs\Sniff;
26
27/**
28 * Fix uses of assertEquals/assertNotEquals or assertSame/assertNotSame with the actual value before the expected
29 * Currently, only catches assertions where the actual value is a variable, or at least
30 * starts with a variable token, and the expected is a literal value or a variable in the form
31 * $expected*, or an array of such values (including nested arrays).
32 *
33 * @author DannyS712
34 */
35class AssertionOrderSniff implements Sniff {
36    use PHPUnitTestTrait;
37
38    private const ASSERTIONS = [
39        'assertEquals' => true,
40        'assertSame' => true,
41        'assertNotEquals' => true,
42        'assertNotSame' => true,
43    ];
44
45    private const LITERALS = [
46        T_NULL => T_NULL,
47        T_FALSE => T_FALSE,
48        T_TRUE => T_TRUE,
49        T_LNUMBER => T_LNUMBER,
50        T_DNUMBER => T_DNUMBER,
51        T_CONSTANT_ENCAPSED_STRING => T_CONSTANT_ENCAPSED_STRING,
52    ];
53
54    /**
55     * @inheritDoc
56     */
57    public function register(): array {
58        return [ T_STRING ];
59    }
60
61    /**
62     * @param File $phpcsFile
63     * @param int $stackPtr
64     *
65     * @return void|int
66     */
67    public function process( File $phpcsFile, $stackPtr ) {
68        if ( !$this->isTestFile( $phpcsFile, $stackPtr ) ) {
69            return $phpcsFile->numTokens;
70        }
71
72        $tokens = $phpcsFile->getTokens();
73        if ( $tokens[$stackPtr]['level'] < 2 ) {
74            // Needs to be in a method in a class
75            return;
76        }
77
78        $assertion = $tokens[$stackPtr]['content'];
79        if ( !isset( self::ASSERTIONS[$assertion] ) ) {
80            // Don't care about this string
81            return;
82        }
83
84        $opener = $phpcsFile->findNext( T_WHITESPACE, $stackPtr + 1, null, true );
85        if ( !isset( $tokens[$opener]['parenthesis_closer'] ) ) {
86            // Needs to be a method call
87            return $opener;
88        }
89
90        $fixInfo = $this->getFixInfo( $phpcsFile, $opener );
91        if ( !$fixInfo ) {
92            // No warning
93            return;
94        }
95        $end = $tokens[$opener]['parenthesis_closer'];
96
97        $fix = $phpcsFile->addFixableWarning(
98            'The expected value goes before the actual value in assertions',
99            $stackPtr,
100            'WrongOrder'
101        );
102        if ( !$fix ) {
103            // There is no way the next assertion can be closer than this
104            return $end + 4;
105        }
106
107        [ $firstParamStart, $firstComma, $afterSecondParam ] = $fixInfo;
108        // The first parameter currently goes from $firstParamStart until $firstComma, and the
109        // second parameter goes from after $firstComma until before $afterSecondParam
110        $actualParamEnd = $phpcsFile->findPrevious( T_WHITESPACE, $firstComma - 1, null, true );
111        $actualParamContent = $phpcsFile->getTokensAsString(
112            $firstParamStart,
113            $actualParamEnd - $firstParamStart + 1,
114            // keep tabs on multiline statements
115            true
116        );
117
118        $expectedParamStart = $phpcsFile->findNext(
119            T_WHITESPACE,
120            $firstComma + 1,
121            $end,
122            true
123        );
124        $expectedParamEnd = $phpcsFile->findPrevious(
125            T_WHITESPACE,
126            $afterSecondParam - 1,
127            null,
128            true
129        );
130        $expectedParamContent = $phpcsFile->getTokensAsString(
131            $expectedParamStart,
132            $expectedParamEnd - $expectedParamStart + 1,
133            // keep tabs on multiline statements
134            true
135        );
136
137        $phpcsFile->fixer->beginChangeset();
138
139        // Remove the first parameter that previously held the actual value,
140        // and replace with the expected
141        $phpcsFile->fixer->replaceToken( $firstParamStart, $expectedParamContent );
142        for ( $i = $firstParamStart + 1; $i <= $actualParamEnd; $i++ ) {
143            $phpcsFile->fixer->replaceToken( $i, '' );
144        }
145
146        // Remove the second parameter that previously held the expeced value,
147        // and replace with the actual
148        $phpcsFile->fixer->replaceToken( $expectedParamStart, $actualParamContent );
149        for ( $i = $expectedParamStart + 1; $i <= $expectedParamEnd; $i++ ) {
150            $phpcsFile->fixer->replaceToken( $i, '' );
151        }
152
153        $phpcsFile->fixer->endChangeset();
154
155        // There is no way the next assertion can be closer than this
156        return $end + 4;
157    }
158
159    /**
160     * @param File $phpcsFile
161     * @param int $opener
162     * @return array|false Array with info for fixing, or false for no change
163     */
164    private function getFixInfo(
165        File $phpcsFile,
166        int $opener
167    ) {
168        $tokens = $phpcsFile->getTokens();
169        $end = $tokens[$opener]['parenthesis_closer'];
170
171        // Optimize for the most common case: the first parameter is a single token
172        // that is a literal, and then there is a comma.
173        $firstParam = $phpcsFile->findNext( T_WHITESPACE, $opener + 1, $end, true );
174        if ( !$firstParam ) {
175            // Assertion is invalid (no parameters) but thats not our problem
176            return false;
177        }
178        if ( isset( self::LITERALS[ $tokens[$firstParam]['code'] ] )
179            && isset( $tokens[$firstParam + 1] )
180            && $tokens[$firstParam + 1]['code'] === T_COMMA
181        ) {
182            return false;
183        }
184
185        // Analyze the assertion call
186        $currentParam = 1;
187        // Whether or not the first parameter has variables or method calls that might
188        // make sense to put as the actual parameter instead
189        $firstParamVariable = false;
190        $firstComma = false;
191        $secondComma = false;
192        $searchTokens = [
193            T_DOUBLE_QUOTED_STRING,
194            T_HEREDOC,
195            T_START_HEREDOC,
196            T_OPEN_CURLY_BRACKET,
197            T_OPEN_SQUARE_BRACKET,
198            T_OPEN_PARENTHESIS,
199            T_OPEN_SHORT_ARRAY,
200            T_CLOSE_SHORT_ARRAY,
201            T_STRING,
202            T_VARIABLE,
203            T_COMMA,
204        ];
205        $next = $firstParam;
206        // For ignoring commas within a literal array in the actual (but should be expected)
207        // parameter, including nested arrays, keep track of the closing of the outermost
208        // current array
209        $arrayEndIndex = -1;
210        while ( $secondComma === false ) {
211            if ( $next === false ) {
212                // If we are in the first parameter and there is no comma,
213                // likely live coding. If we are in the second, then it just
214                // means that there is third parameter (message)
215                if ( $currentParam === 1 ) {
216                    return false;
217                }
218                // Second parameter ended
219                break;
220            }
221            switch ( $tokens[$next]['code'] ) {
222                // Some things we just don't handle
223                case T_DOUBLE_QUOTED_STRING:
224                case T_HEREDOC:
225                case T_START_HEREDOC:
226                    return false;
227
228                case T_OPEN_SHORT_ARRAY:
229                    // Commas within an array in the second parameter should
230                    // not be treated as separating parameters to the assertion,
231                    // the start of a nested array does not change the end of
232                    // the outer array
233                    if ( $currentParam === 2
234                        && $arrayEndIndex === -1
235                        && isset( $tokens[$next]['bracket_closer'] )
236                    ) {
237                        $arrayEndIndex = $tokens[$next]['bracket_closer'];
238                        break;
239                    }
240                    // Intentional fall through for handling first parameter
241
242                case T_OPEN_CURLY_BRACKET:
243                case T_OPEN_SQUARE_BRACKET:
244                case T_OPEN_PARENTHESIS:
245                    // Only skipping to the end of these in the first parameter,
246                    // need to count them in the second one
247                    if ( $currentParam === 1 ) {
248                        if ( isset( $tokens[$next]['parenthesis_closer'] ) ) {
249                            // jump to closing parenthesis to ignore commas between opener and closer
250                            $next = $tokens[$next]['parenthesis_closer'];
251                        } elseif ( isset( $tokens[$next]['bracket_closer'] ) ) {
252                            // jump to closing bracket
253                            $next = $tokens[$next]['bracket_closer'];
254                        }
255                    }
256                    break;
257
258                case T_CLOSE_SHORT_ARRAY:
259                    // If we reached the end of the correct array in the
260                    // second parameter, further commas should be treated as
261                    // separating parameters to the assertion
262                    if ( $next === $arrayEndIndex ) {
263                        $arrayEndIndex = -1;
264                    }
265                    break;
266
267                case T_VARIABLE:
268                    if ( $currentParam === 2 ) {
269                        // We are looking at the second parameter, which
270                        // should be the actual value. Since the actual value
271                        // includes a variable or function call, its probably correct,
272                        // unless the variable is named $expected*, in which
273                        // case we can assume that it was meant to be the
274                        // expected value, not the actual value
275                        $expectedVarName = $tokens[$next]['content'];
276                        // optimize for common case - full name is $expected
277                        if ( $expectedVarName !== '$expected'
278                            // but also handle $expectedRes and similar
279                            && !str_starts_with( $expectedVarName, '$expected' )
280                        ) {
281                            return false;
282                        }
283                        // Don't set $firstParamVariable if this is the
284                        // second param
285                        break;
286                    }
287                    $firstParamVariable = true;
288                    break;
289
290                case T_STRING:
291                    // Check if its a function call
292                    $functionOpener = $phpcsFile->findNext(
293                        T_WHITESPACE,
294                        $next + 1,
295                        $end,
296                        true
297                    );
298                    if ( $functionOpener &&
299                        isset( $tokens[$functionOpener]['parenthesis_closer'] )
300                    ) {
301                        // Function call, similar to T_VARIABLE handling
302                        if ( $currentParam === 2 ) {
303                            return false;
304                        }
305                        $firstParamVariable = true;
306                        // Jump over the function call, no need to wait until
307                        // the next iteration triggers with T_OPEN_PARENTHESIS
308                        $next = $tokens[$functionOpener]['parenthesis_closer'];
309                    }
310                    break;
311
312                case T_COMMA:
313                    // Ignore commas within arrays
314                    if ( $arrayEndIndex !== -1 ) {
315                        break;
316                    }
317                    if ( $currentParam === 1 ) {
318                        if ( $firstParamVariable === false ) {
319                            // No need to check the second parameter,
320                            // the first one had no variables or
321                            // method calls that would make sense as
322                            // the actual parameter
323                            return false;
324                        }
325                        $firstComma = $next;
326                        $currentParam = 2;
327                    } else {
328                        // Triggers the end of the while loop
329                        $secondComma = $next;
330                    }
331                    break;
332            }
333            $next = $phpcsFile->findNext( $searchTokens, $next + 1, $end );
334        }
335
336        // If we got here, then there were no variables or methods in the second parameter,
337        // which should have been the actual value. Should switch with the first parameter.
338        $afterSecondParam = ( $secondComma ?: $end );
339        return [ $firstParam, $firstComma, $afterSecondParam ];
340    }
341
342}