Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 493
0.00% covered (danger)
0.00%
0 / 7
CRAP
0.00% covered (danger)
0.00%
0 / 1
FunctionCommentSniff
0.00% covered (danger)
0.00%
0 / 493
0.00% covered (danger)
0.00%
0 / 7
19182
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 / 67
0.00% covered (danger)
0.00%
0 / 1
756
 processReturn
0.00% covered (danger)
0.00%
0 / 88
0.00% covered (danger)
0.00%
0 / 1
342
 functionReturnsValue
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
156
 processThrows
0.00% covered (danger)
0.00%
0 / 53
0.00% covered (danger)
0.00%
0 / 1
182
 processParams
0.00% covered (danger)
0.00%
0 / 258
0.00% covered (danger)
0.00%
0 / 1
4032
 replaceParamComment
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
20
1<?php
2/**
3 * This file was copied from PHP_CodeSniffer before being modified
4 * File: Standards/PEAR/Sniffs/Commenting/FunctionCommentSniff.php
5 * From repository: https://github.com/squizlabs/PHP_CodeSniffer
6 *
7 * Parses and verifies the doc comments for functions.
8 *
9 * PHP version 5
10 *
11 * @category PHP
12 * @package PHP_CodeSniffer
13 * @author Greg Sherwood <gsherwood@squiz.net>
14 * @author Marc McIntyre <mmcintyre@squiz.net>
15 * @copyright 2006-2014 Squiz Pty Ltd (ABN 77 084 670 600)
16 * @license https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD-3-Clause
17 * @link http://pear.php.net/package/PHP_CodeSniffer
18 */
19
20namespace MediaWiki\Sniffs\Commenting;
21
22use MediaWiki\Sniffs\PHPUnit\PHPUnitTestTrait;
23use PHP_CodeSniffer\Files\File;
24use PHP_CodeSniffer\Sniffs\Sniff;
25use PHP_CodeSniffer\Util\Tokens;
26
27class FunctionCommentSniff implements Sniff {
28
29    use DocumentationTypeTrait;
30    use PHPUnitTestTrait;
31
32    /**
33     * Standard class methods that
34     * don't require documentation
35     */
36    private const SKIP_STANDARD_METHODS = [
37        '__toString',
38        '__destruct',
39        '__sleep',
40        '__wakeup',
41        '__clone',
42        '__invoke',
43        '__call',
44        '__callStatic',
45        '__get',
46        '__set',
47        '__isset',
48        '__unset',
49        '__serialize',
50        '__unserialize',
51        '__set_state',
52        '__debugInfo',
53    ];
54
55    /**
56     * @inheritDoc
57     */
58    public function register(): array {
59        return [ T_FUNCTION ];
60    }
61
62    /**
63     * Processes this test, when one of its tokens is encountered.
64     *
65     * @param File $phpcsFile The file being scanned.
66     * @param int $stackPtr The position of the current token in the stack passed in $tokens.
67     *
68     * @return void
69     */
70    public function process( File $phpcsFile, $stackPtr ) {
71        $funcName = $phpcsFile->getDeclarationName( $stackPtr );
72        if ( $funcName === null || in_array( $funcName, self::SKIP_STANDARD_METHODS ) ) {
73            // Don't require documentation for an obvious method
74            return;
75        }
76
77        $tokens = $phpcsFile->getTokens();
78        $find = Tokens::$methodPrefixes;
79        $find[] = T_WHITESPACE;
80        $searchBefore = $stackPtr;
81        $linesBetweenDocAndFunction = 1;
82        do {
83            $commentEnd = $phpcsFile->findPrevious( $find, $searchBefore - 1, null, true );
84            // Allow attributes between doc block and function, T306941
85            if ( isset( $tokens[$commentEnd]['attribute_opener'] ) ) {
86                $searchBefore = $tokens[$commentEnd]['attribute_opener'];
87                // Attributes should be on their own lines
88                $linesBetweenDocAndFunction++;
89                continue;
90            }
91            break;
92        } while ( true );
93        if ( $tokens[$commentEnd]['code'] === T_COMMENT ) {
94            // Inline comments might just be closing comments for
95            // control structures or functions instead of function comments
96            // using the wrong comment type. If there is other code on the line,
97            // assume they relate to that code.
98            $prev = $phpcsFile->findPrevious( $find, $commentEnd - 1, null, true );
99            if ( $prev !== false && $tokens[$prev]['line'] === $tokens[$commentEnd]['line'] ) {
100                $commentEnd = $prev;
101            }
102        }
103        if ( $tokens[$commentEnd]['code'] !== T_DOC_COMMENT_CLOSE_TAG
104            && $tokens[$commentEnd]['code'] !== T_COMMENT
105        ) {
106            // Function has no documentation; check if this is allowed or not
107            $methodProps = $phpcsFile->getMethodProperties( $stackPtr );
108            $methodParams = $phpcsFile->getMethodParameters( $stackPtr );
109            $hasReturnType = $methodProps['return_type'] !== '' || $funcName === '__construct';
110            $hasParams = $methodParams !== [];
111            $getterWithoutParams = !$hasParams && preg_match( '/^(get|is)[A-Z]/', $funcName );
112            $allParamsTyped = true;
113            foreach ( $methodParams as $parameter ) {
114                if ( $parameter['type_hint'] === '' ) {
115                    $allParamsTyped = false;
116                    break;
117                }
118            }
119            // Enforce strict return type or @return documentation for interfaces/abstract methods,
120            // but only if they are entirely undocumented at the moment
121            $returnsValue = $this->functionReturnsValue( $phpcsFile, $stackPtr ) ?? true;
122            $isFullyTyped = $allParamsTyped && ( $hasReturnType || !$returnsValue );
123            $isTestFile = $this->isTestFile( $phpcsFile, $stackPtr );
124            // A function is *allowed* to omit the documentation comment
125            // (but in many cases, documentation comments still make sense, and are not discouraged)
126            // if it is fully typed (parameter and return type declarations), or in a test file,
127            // or has no parameters and is not a getter.
128            // The last exception, allowing parameterless non-getters to omit their return type, may be removed later.
129            if ( !$isFullyTyped && !$isTestFile && ( $getterWithoutParams || $hasParams ) ) {
130                $phpcsFile->addError(
131                    'Missing function doc comment',
132                    $stackPtr,
133                    // Messages used: MissingDocumentationPublic, MissingDocumentationProtected,
134                    // MissingDocumentationPrivate
135                    'MissingDocumentation' . ucfirst( $methodProps['scope'] )
136                );
137            }
138            return;
139        }
140        if ( $tokens[$commentEnd]['code'] === T_COMMENT ) {
141            $phpcsFile->addError(
142                'You must use "/**" style comments for a function comment',
143                $stackPtr,
144                'WrongStyle'
145            );
146            return;
147        }
148        if ( $tokens[$commentEnd]['line'] !== $tokens[$stackPtr]['line'] - $linesBetweenDocAndFunction ) {
149            $phpcsFile->addError(
150                'There must be no blank lines after the function comment',
151                $commentEnd,
152                'SpacingAfter'
153            );
154        }
155        $commentStart = $tokens[$commentEnd]['comment_opener'];
156
157        foreach ( $tokens[$commentStart]['comment_tags'] as $tag ) {
158            $tagText = $tokens[$tag]['content'];
159            if ( strcasecmp( $tagText, '@inheritDoc' ) === 0 || $tagText === '@deprecated' ) {
160                // No need to validate deprecated functions or those that inherit
161                // their documentation
162                return;
163            }
164        }
165
166        // Don't validate functions with {@inheritDoc}, per T270830
167        // Not available in comment_tags, need to check manually
168        $end = reset( $tokens[$commentStart]['comment_tags'] ) ?: $commentEnd;
169        $rawComment = $phpcsFile->getTokensAsString( $commentStart + 1, $end - $commentStart - 1 );
170        if ( stripos( $rawComment, '{@inheritDoc}' ) !== false ) {
171            return;
172        }
173
174        if ( $funcName !== '__construct' ) {
175            $this->processReturn( $phpcsFile, $stackPtr, $commentStart );
176        }
177        $this->processThrows( $phpcsFile, $commentStart );
178        $this->processParams( $phpcsFile, $stackPtr, $commentStart );
179    }
180
181    /**
182     * Process the return comment of this function comment.
183     *
184     * @param File $phpcsFile The file being scanned.
185     * @param int $stackPtr The position of the current token in the stack passed in $tokens.
186     * @param int $commentStart The position in the stack where the comment started.
187     */
188    protected function processReturn( File $phpcsFile, int $stackPtr, int $commentStart ): void {
189        $tokens = $phpcsFile->getTokens();
190
191        $hasReturnType = $phpcsFile->getMethodProperties( $stackPtr )['return_type'] !== '';
192        // Assume interfaces/abstract methods don't return anything when they have some comment
193        // already, no matter what the comment says
194        $returnsValue = $this->functionReturnsValue( $phpcsFile, $stackPtr ) ?? false;
195
196        $returnPtr = null;
197        foreach ( $tokens[$commentStart]['comment_tags'] as $ptr ) {
198            if ( $tokens[$ptr]['content'] !== '@return' ) {
199                continue;
200            }
201            if ( $returnPtr ) {
202                $phpcsFile->addError( 'Only 1 @return tag is allowed in a function comment', $ptr, 'DuplicateReturn' );
203                return;
204            }
205            $returnPtr = $ptr;
206        }
207        if ( $returnPtr !== null ) {
208            $retTypeSpacingPtr = $returnPtr + 1;
209            // Check spaces before type
210            if ( $tokens[$retTypeSpacingPtr]['code'] === T_DOC_COMMENT_WHITESPACE ) {
211                $expectedSpaces = 1;
212                $currentSpaces = strlen( $tokens[$retTypeSpacingPtr]['content'] );
213                if ( $currentSpaces !== $expectedSpaces ) {
214                    $fix = $phpcsFile->addFixableWarning(
215                        'Expected %s spaces before return type; %s found',
216                        $retTypeSpacingPtr,
217                        'SpacingBeforeReturnType',
218                        [ $expectedSpaces, $currentSpaces ]
219                    );
220                    if ( $fix ) {
221                        $phpcsFile->fixer->replaceToken( $retTypeSpacingPtr, ' ' );
222                    }
223                }
224            }
225            $retTypePtr = $returnPtr + 2;
226            $content = '';
227            if ( $tokens[$retTypePtr]['code'] === T_DOC_COMMENT_STRING ) {
228                $content = $tokens[$retTypePtr]['content'];
229            }
230            if ( $content === '' ) {
231                $phpcsFile->addError(
232                    'Return type missing for @return tag in function comment',
233                    $returnPtr,
234                    'MissingReturnType'
235                );
236                return;
237            }
238            [ $type, $separatorLength, $comment ] = $this->splitTypeAndComment( $content );
239            $fixType = false;
240            // Check for unneeded punctuation
241            $type = $this->fixTrailingPunctuation(
242                $phpcsFile,
243                $retTypePtr,
244                $type,
245                $fixType,
246                'return type'
247            );
248            $type = $this->fixWrappedParenthesis(
249                $phpcsFile,
250                $retTypePtr,
251                $type,
252                $fixType,
253                'return type'
254            );
255            // Check the type for short types
256            $type = $this->fixShortTypes( $phpcsFile, $retTypePtr, $type, $fixType, 'return' );
257            $this->maybeAddObjectTypehintError(
258                $phpcsFile,
259                $retTypePtr,
260                $type,
261                'return'
262            );
263            $this->maybeAddTypeTypehintError(
264                $phpcsFile,
265                $retTypePtr,
266                $type,
267                'return'
268            );
269            // Check spacing after type
270            if ( $comment !== '' ) {
271                $expectedSpaces = 1;
272                if ( $separatorLength !== $expectedSpaces ) {
273                    $fix = $phpcsFile->addFixableWarning(
274                        'Expected %s spaces after return type; %s found',
275                        $retTypePtr,
276                        'SpacingAfterReturnType',
277                        [ $expectedSpaces, $separatorLength ]
278                    );
279                    if ( $fix ) {
280                        $fixType = true;
281                        $separatorLength = $expectedSpaces;
282                    }
283                }
284            }
285            if ( $fixType ) {
286                $phpcsFile->fixer->replaceToken(
287                    $retTypePtr,
288                    $type . ( $comment !== '' ? str_repeat( ' ', $separatorLength ) . $comment : '' )
289                );
290            }
291        } elseif ( $returnsValue && !$hasReturnType && !$this->isTestFunction( $phpcsFile, $stackPtr ) ) {
292            $phpcsFile->addError(
293                'Missing return type or @return tag in function comment',
294                $tokens[$commentStart]['comment_closer'],
295                'MissingReturn'
296            );
297        }
298    }
299
300    private function functionReturnsValue( File $phpcsFile, int $stackPtr ): ?bool {
301        $tokens = $phpcsFile->getTokens();
302
303        // Interfaces or abstract functions don't have a body
304        if ( !isset( $tokens[$stackPtr]['scope_closer'] ) ) {
305            return null;
306        }
307
308        for ( $i = $tokens[$stackPtr]['scope_closer'] - 1; $i > $stackPtr; $i-- ) {
309            $token = $tokens[$i];
310            if ( isset( $token['scope_condition'] ) ) {
311                $scope = $tokens[$token['scope_condition']]['code'];
312                if ( $scope === T_FUNCTION || $scope === T_CLOSURE || $scope === T_ANON_CLASS ) {
313                    // Skip to the other side of the closure/inner function and continue
314                    $i = $token['scope_condition'];
315                    continue;
316                }
317            }
318            if ( $token['code'] === T_YIELD || $token['code'] === T_YIELD_FROM ) {
319                return true;
320            } elseif ( $token['code'] === T_RETURN &&
321                // Ignore empty `return;` and continue searching
322                isset( $tokens[$i + 1] ) && $tokens[$i + 1]['code'] !== T_SEMICOLON
323            ) {
324                return true;
325            }
326        }
327        return false;
328    }
329
330    /**
331     * Process any throw tags that this function comment has.
332     *
333     * @param File $phpcsFile The file being scanned.
334     * @param int $commentStart The position in the stack where the comment started.
335     */
336    protected function processThrows( File $phpcsFile, int $commentStart ): void {
337        $tokens = $phpcsFile->getTokens();
338        foreach ( $tokens[$commentStart]['comment_tags'] as $tag ) {
339            if ( $tokens[$tag]['content'] !== '@throws' ) {
340                continue;
341            }
342            // Check spaces before exception
343            if ( $tokens[$tag + 1]['code'] === T_DOC_COMMENT_WHITESPACE ) {
344                $expectedSpaces = 1;
345                $currentSpaces = strlen( $tokens[$tag + 1]['content'] );
346                if ( $currentSpaces !== $expectedSpaces ) {
347                    $fix = $phpcsFile->addFixableWarning(
348                        'Expected %s spaces before exception type; %s found',
349                        $tag + 1,
350                        'SpacingBeforeExceptionType',
351                        [ $expectedSpaces, $currentSpaces ]
352                    );
353                    if ( $fix ) {
354                        $phpcsFile->fixer->replaceToken( $tag + 1, ' ' );
355                    }
356                }
357            }
358            $exception = '';
359            $comment = '';
360            $separatorLength = null;
361            if ( $tokens[$tag + 2]['code'] === T_DOC_COMMENT_STRING ) {
362                [ $exception, $separatorLength, $comment ] = $this->splitTypeAndComment( $tokens[$tag + 2]['content'] );
363            }
364            if ( $exception === '' ) {
365                $phpcsFile->addError(
366                    'Exception type missing for @throws tag in function comment',
367                    $tag,
368                    'InvalidThrows'
369                );
370                continue;
371            }
372            $fixType = false;
373            $exception = $this->fixWrappedParenthesis(
374                $phpcsFile,
375                $tag,
376                $exception,
377                $fixType,
378                'exception type'
379            );
380            // Check spacing after exception
381            if ( $comment !== '' ) {
382                $expectedSpaces = 1;
383                if ( $separatorLength !== $expectedSpaces ) {
384                    $fix = $phpcsFile->addFixableWarning(
385                        'Expected %s spaces after exception type; %s found',
386                        $tag + 2,
387                        'SpacingAfterExceptionType',
388                        [ $expectedSpaces, $separatorLength ]
389                    );
390                    if ( $fix ) {
391                        $fixType = true;
392                        $separatorLength = $expectedSpaces;
393                    }
394                }
395            }
396            if ( $fixType ) {
397                $phpcsFile->fixer->replaceToken(
398                    $tag + 2,
399                    $exception . ( $comment !== '' ? str_repeat( ' ', $separatorLength ) . $comment : '' )
400                );
401            }
402        }
403    }
404
405    /**
406     * Process the function parameter comments.
407     *
408     * @param File $phpcsFile The file being scanned.
409     * @param int $stackPtr The position of the current token in the stack passed in $tokens.
410     * @param int $commentStart The position in the stack where the comment started.
411     */
412    protected function processParams( File $phpcsFile, int $stackPtr, int $commentStart ): void {
413        $tokens = $phpcsFile->getTokens();
414        $params = [];
415        foreach ( $tokens[$commentStart]['comment_tags'] as $pos => $tag ) {
416            if ( $tokens[$tag]['content'] !== '@param' ) {
417                continue;
418            }
419
420            $paramSpace = 0;
421            $type = '';
422            $typeSpace = 0;
423            $var = '';
424            $varSpace = 0;
425            $comment = '';
426            $commentFirst = '';
427            if ( $tokens[$tag + 1]['code'] === T_DOC_COMMENT_WHITESPACE ) {
428                $paramSpace = strlen( $tokens[$tag + 1]['content'] );
429            }
430            if ( $tokens[$tag + 2]['code'] === T_DOC_COMMENT_STRING ) {
431                preg_match( '/^
432                        # Match parameter type and separator as a group of
433                        ((?:
434                            # plain letters
435                            [^&$.\{\[]
436                            |
437                            # or pairs of braces around plain letters, never single braces
438                            \{ [^&$.\{\}]* \}
439                            |
440                            # or pairs of brackets around plain letters, never single brackets
441                            \[ [^&$.\[\]]* \]
442                            |
443                            # allow & on intersect types, but not as pass-by-ref
444                            & [^$.\[\]]
445                        )*) (?:
446                            # Match parameter name with variadic arg or surround by {} or []
447                            ( (?: \.\.\. | [\[\{] )? [&$] \S+ )
448                            # Match optional rest of line
449                            (?: (\s+) (.*) )?
450                        )? /x',
451                    $tokens[$tag + 2]['content'],
452                    $matches
453                );
454                $untrimmedType = $matches[1] ?? '';
455                $type = rtrim( $untrimmedType );
456                $typeSpace = strlen( $untrimmedType ) - strlen( $type );
457                if ( isset( $matches[2] ) ) {
458                    $var = $matches[2];
459                    if ( isset( $matches[4] ) ) {
460                        $varSpace = strlen( $matches[3] );
461                        $commentFirst = $matches[4];
462                        $comment = $commentFirst;
463                        // Any strings until the next tag belong to this comment.
464                        $end = $tokens[$commentStart]['comment_tags'][$pos + 1] ??
465                            $tokens[$commentStart]['comment_closer'];
466                        for ( $i = $tag + 3; $i < $end; $i++ ) {
467                            if ( $tokens[$i]['code'] === T_DOC_COMMENT_STRING ) {
468                                $comment .= ' ' . $tokens[$i]['content'];
469                            }
470                        }
471                    }
472                } else {
473                    $phpcsFile->addError( 'Missing parameter name', $tag, 'MissingParamName' );
474                }
475            } else {
476                $phpcsFile->addError( 'Missing parameter type', $tag, 'MissingParamType' );
477            }
478
479            $isPassByReference = str_starts_with( $var, '&' );
480            // Remove the pass by reference to allow compare with varargs
481            if ( $isPassByReference ) {
482                $var = substr( $var, 1 );
483            }
484
485            $isLegacyVariadicArg = str_ends_with( $var, ',...' );
486            $isVariadicArg = str_starts_with( $var, '...$' );
487            // Remove the variadic indicator from the doc name to compare it against the real
488            // name, so that we can allow both formats.
489            if ( $isLegacyVariadicArg ) {
490                $var = substr( $var, 0, -4 );
491            } elseif ( $isVariadicArg ) {
492                $var = substr( $var, 3 );
493            }
494
495            $params[] = [
496                'tag' => $tag,
497                'type' => $type,
498                'var' => $var,
499                'variadic_arg' => $isVariadicArg,
500                'legacy_variadic_arg' => $isLegacyVariadicArg,
501                'pass_by_reference' => $isPassByReference,
502                'comment' => $comment,
503                'comment_first' => $commentFirst,
504                'param_space' => $paramSpace,
505                'type_space' => $typeSpace,
506                'var_space' => $varSpace,
507            ];
508        }
509        $realParams = $phpcsFile->getMethodParameters( $stackPtr );
510        $foundParams = [];
511        // We want to use ... for all variable length arguments, so added
512        // this prefix to the variable name so comparisons are easier.
513        foreach ( $realParams as $pos => $param ) {
514            if ( $param['variable_length'] === true ) {
515                $realParams[$pos]['name'] = '...' . $param['name'];
516            }
517        }
518        foreach ( $params as $pos => $param ) {
519            if ( $param['var'] === '' ) {
520                continue;
521            }
522            // Check number of spaces before type (after @param)
523            $spaces = 1;
524            if ( $param['param_space'] !== $spaces ) {
525                $fix = $phpcsFile->addFixableWarning(
526                    'Expected %s spaces before parameter type; %s found',
527                    $param['tag'],
528                    'SpacingBeforeParamType',
529                    [ $spaces, $param['param_space'] ]
530                );
531                if ( $fix ) {
532                    $phpcsFile->fixer->replaceToken( $param['tag'] + 1, str_repeat( ' ', $spaces ) );
533                }
534            }
535            // Check if type is provided
536            if ( $param['type'] === '' ) {
537                $phpcsFile->addError(
538                    'Expected parameter type before parameter name "%s"',
539                    $param['tag'],
540                    'NoParamType',
541                    [ $param['var'] ]
542                );
543            } else {
544                // Check number of spaces after the type.
545                if ( $param['type_space'] !== $spaces ) {
546                    $fix = $phpcsFile->addFixableWarning(
547                        'Expected %s spaces after parameter type; %s found',
548                        $param['tag'],
549                        'SpacingAfterParamType',
550                        [ $spaces, $param['type_space'] ]
551                    );
552                    if ( $fix ) {
553                        $this->replaceParamComment(
554                            $phpcsFile,
555                            $param,
556                            [ 'type_space' => $spaces ]
557                        );
558                    }
559                }
560
561            }
562            $fixVar = false;
563            $var = $this->fixTrailingPunctuation(
564                $phpcsFile,
565                $param['tag'],
566                $param['var'],
567                $fixVar,
568                'param name'
569            );
570            $var = $this->fixWrappedParenthesis(
571                $phpcsFile,
572                $param['tag'],
573                $var,
574                $fixVar,
575                'param name'
576            );
577            if ( $fixVar ) {
578                $this->replaceParamComment(
579                    $phpcsFile,
580                    $param,
581                    [ 'var' => $var ]
582                );
583            }
584            // Make sure the param name is correct.
585            $defaultNull = false;
586            if ( isset( $realParams[$pos] ) ) {
587                $realName = $realParams[$pos]['name'];
588                // If difference is pass by reference, add or remove & from documentation
589                if ( $param['pass_by_reference'] !== $realParams[$pos]['pass_by_reference'] ) {
590                    $fix = $phpcsFile->addFixableError(
591                        'Pass-by-reference for parameter %s does not match ' .
592                            'pass-by-reference of variable name %s',
593                        $param['tag'],
594                        'ParamPassByReference',
595                        [ $var, $realName ]
596                    );
597                    if ( $fix ) {
598                        $this->replaceParamComment(
599                            $phpcsFile,
600                            $param,
601                            [ 'pass_by_reference' => $realParams[$pos]['pass_by_reference'] ]
602                        );
603                    }
604                    $param['pass_by_reference'] = $realParams[$pos]['pass_by_reference'];
605                }
606                if ( $realName !== $var ) {
607                    if (
608                        str_starts_with( $realName, '...$' ) &&
609                        ( $param['legacy_variadic_arg'] || $param['variadic_arg'] )
610                    ) {
611                        // Mark all variants as found
612                        $foundParams[] = "...$var";
613                        $foundParams[] = "$var,...";
614                    } else {
615                        $code = 'ParamNameNoMatch';
616                        $error = 'Doc comment for parameter %s does not match ';
617                        if ( strcasecmp( $var, $realName ) === 0 ) {
618                            $error .= 'case of ';
619                            $code = 'ParamNameNoCaseMatch';
620                        }
621                        $error .= 'actual variable name %s';
622                        $phpcsFile->addError( $error, $param['tag'], $code, [ $var, $realName ] );
623                    }
624                }
625                $defaultNull = ( $realParams[$pos]['default'] ?? '' ) === 'null';
626            } elseif ( $param['variadic_arg'] || $param['legacy_variadic_arg'] ) {
627                $phpcsFile->addError(
628                    'Variadic parameter documented but not present in the signature',
629                    $param['tag'],
630                    'VariadicDocNotSignature'
631                );
632            } else {
633                $phpcsFile->addError( 'Superfluous parameter comment', $param['tag'], 'ExtraParamComment' );
634            }
635            $foundParams[] = $var;
636            $fixType = false;
637            // Check for unneeded punctuation on parameter type
638            $type = $this->fixWrappedParenthesis(
639                $phpcsFile,
640                $param['tag'],
641                $param['type'],
642                $fixType,
643                'param type'
644            );
645            // Check the short type of boolean and integer
646            $type = $this->fixShortTypes( $phpcsFile, $param['tag'], $type, $fixType, 'param' );
647            $this->maybeAddObjectTypehintError(
648                $phpcsFile,
649                $param['tag'],
650                $type,
651                'param'
652            );
653            $this->maybeAddTypeTypehintError(
654                $phpcsFile,
655                $param['tag'],
656                $type,
657                'param'
658            );
659            $explodedType = $type === '' ? [] : explode( '|', $type );
660            $nullableDoc = str_starts_with( $type, '?' );
661            $nullFound = false;
662            foreach ( $explodedType as $index => $singleType ) {
663                $singleType = lcfirst( $singleType );
664                // Either an explicit null, or mixed, which null is a
665                // part of (T218324)
666                if ( $singleType === 'null' || $singleType === 'mixed' ) {
667                    $nullFound = true;
668                } elseif ( str_ends_with( $singleType, '[optional]' ) ) {
669                    $fix = $phpcsFile->addFixableError(
670                        'Key word "[optional]" on "%s" should not be used',
671                        $param['tag'],
672                        'NoOptionalKeyWord',
673                        [ $param['type'] ]
674                    );
675                    if ( $fix ) {
676                        $explodedType[$index] = substr( $singleType, 0, -10 );
677                        $fixType = true;
678                    }
679                }
680            }
681            if (
682                isset( $realParams[$pos] ) && $nullableDoc && $defaultNull &&
683                !$realParams[$pos]['nullable_type']
684            ) {
685                // Don't offer autofix, as changing a signature is somewhat delicate
686                $phpcsFile->addError(
687                    'Use nullable type("%s") for parameters documented as nullable',
688                    $realParams[$pos]['token'],
689                    'PHP71NullableDocOptionalArg',
690                    [ $type ]
691                );
692            } elseif ( $defaultNull && !( $nullFound || $nullableDoc ) ) {
693                // Check if the default of null is in the type list
694                $fix = $phpcsFile->addFixableError(
695                    'Default of null should be declared in @param tag',
696                    $param['tag'],
697                    'DefaultNullTypeParam'
698                );
699                if ( $fix ) {
700                    $explodedType[] = 'null';
701                    $fixType = true;
702                }
703            }
704
705            if ( $fixType ) {
706                $this->replaceParamComment(
707                    $phpcsFile,
708                    $param,
709                    [ 'type' => implode( '|', $explodedType ) ]
710                );
711            }
712            if ( $param['comment'] === '' ) {
713                continue;
714            }
715            // Check number of spaces after the var name.
716            if ( $param['var_space'] !== $spaces &&
717                ltrim( $param['comment'] ) !== ''
718            ) {
719                $fix = $phpcsFile->addFixableWarning(
720                    'Expected %s spaces after parameter name; %s found',
721                    $param['tag'],
722                    'SpacingAfterParamName',
723                    [ $spaces, $param['var_space'] ]
724                );
725                if ( $fix ) {
726                    $this->replaceParamComment(
727                        $phpcsFile,
728                        $param,
729                        [ 'var_space' => $spaces ]
730                    );
731                }
732            }
733            // Warn if the parameter is documented as variadic, but the signature doesn't have
734            // the splat operator
735            if (
736                ( $param['variadic_arg'] || $param['legacy_variadic_arg'] ) &&
737                isset( $realParams[$pos] ) &&
738                $realParams[$pos]['variable_length'] === false
739            ) {
740                $legacyName = $param['legacy_variadic_arg'] ? "$var,..." : "...$var";
741                $phpcsFile->addError(
742                    'Splat operator missing for documented variadic parameter "%s"',
743                    $realParams[$pos]['token'],
744                    'MissingSplatVariadicArg',
745                    [ $legacyName ]
746                );
747            }
748        }
749        $missingParams = [];
750        $hasUntypedParams = false;
751        foreach ( $realParams as $param ) {
752            $hasUntypedParams = $hasUntypedParams || $param['type_hint'] === '';
753            if ( !in_array( $param['name'], $foundParams ) ) {
754                $missingParams[] = $param['name'];
755            }
756        }
757        $isTestFunction = $this->isTestFunction( $phpcsFile, $stackPtr );
758        // Report missing comments, unless *all* parameters have types.
759        // As an exception, tests are allowed to omit comments an long as they omit *all* comments.
760        if ( $hasUntypedParams && ( !$isTestFunction || $foundParams !== [] ) ) {
761            foreach ( $missingParams as $neededParam ) {
762                $phpcsFile->addError(
763                    'Doc comment for parameter "%s" missing',
764                    $commentStart,
765                    'MissingParamTag',
766                    [ $neededParam ]
767                );
768            }
769        }
770    }
771
772    /**
773     * Replace a {@}param comment
774     *
775     * @param File $phpcsFile The file being scanned.
776     * @param array $param Array of the @param
777     * @param array $fixParam Array with fixes to @param. Only provide keys to replace
778     */
779    protected function replaceParamComment( File $phpcsFile, array $param, array $fixParam ): void {
780        // Use the old value for unchanged keys
781        $fixParam += $param;
782
783        // Build the new line
784        $content = $fixParam['type'] .
785            str_repeat( ' ', $fixParam['type_space'] ) .
786            ( $fixParam['pass_by_reference'] ? '&' : '' ) .
787            ( $fixParam['variadic_arg'] ? '...' : '' ) .
788            $fixParam['var'] .
789            ( $fixParam['legacy_variadic_arg'] ? ',...' : '' ) .
790            str_repeat( ' ', $fixParam['var_space'] ) .
791            $fixParam['comment_first'];
792        $phpcsFile->fixer->replaceToken( $fixParam['tag'] + 2, $content );
793    }
794
795}