1<?php
2/**
3 * Checks the separation between functions and methods.
4 *
5 * @author    Greg Sherwood <gsherwood@squiz.net>
6 * @copyright 2006-2015 Squiz Pty Ltd (ABN 77 084 670 600)
7 * @license   https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence
8 */
9
10namespace PHP_CodeSniffer\Standards\Squiz\Sniffs\WhiteSpace;
11
12use PHP_CodeSniffer\Files\File;
13use PHP_CodeSniffer\Sniffs\Sniff;
14use PHP_CodeSniffer\Util\Tokens;
15
16class FunctionSpacingSniff implements Sniff
17{
18
19    /**
20     * The number of blank lines between functions.
21     *
22     * @var integer
23     */
24    public $spacing = 2;
25
26    /**
27     * The number of blank lines before the first function in a class.
28     *
29     * @var integer
30     */
31    public $spacingBeforeFirst = 2;
32
33    /**
34     * The number of blank lines after the last function in a class.
35     *
36     * @var integer
37     */
38    public $spacingAfterLast = 2;
39
40    /**
41     * Original properties as set in a custom ruleset (if any).
42     *
43     * @var array|null
44     */
45    private $rulesetProperties = null;
46
47
48    /**
49     * Returns an array of tokens this test wants to listen for.
50     *
51     * @return array
52     */
53    public function register()
54    {
55        return [T_FUNCTION];
56
57    }//end register()
58
59
60    /**
61     * Processes this sniff when one of its tokens is encountered.
62     *
63     * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
64     * @param int                         $stackPtr  The position of the current token
65     *                                               in the stack passed in $tokens.
66     *
67     * @return void
68     */
69    public function process(File $phpcsFile, $stackPtr)
70    {
71        $tokens           = $phpcsFile->getTokens();
72        $previousNonEmpty = $phpcsFile->findPrevious(Tokens::$emptyTokens, ($stackPtr - 1), null, true);
73        if ($previousNonEmpty !== false
74            && $tokens[$previousNonEmpty]['code'] === T_OPEN_TAG
75            && $tokens[$previousNonEmpty]['line'] !== 1
76        ) {
77            // Ignore functions at the start of an embedded PHP block.
78            return;
79        }
80
81        // If the ruleset has only overridden the spacing property, use
82        // that value for all spacing rules.
83        if ($this->rulesetProperties === null) {
84            $this->rulesetProperties = [];
85            if (isset($phpcsFile->ruleset->ruleset['Squiz.WhiteSpace.FunctionSpacing']) === true
86                && isset($phpcsFile->ruleset->ruleset['Squiz.WhiteSpace.FunctionSpacing']['properties']) === true
87            ) {
88                $this->rulesetProperties = $phpcsFile->ruleset->ruleset['Squiz.WhiteSpace.FunctionSpacing']['properties'];
89                if (isset($this->rulesetProperties['spacing']) === true) {
90                    if (isset($this->rulesetProperties['spacingBeforeFirst']) === false) {
91                        $this->spacingBeforeFirst = $this->spacing;
92                    }
93
94                    if (isset($this->rulesetProperties['spacingAfterLast']) === false) {
95                        $this->spacingAfterLast = $this->spacing;
96                    }
97                }
98            }
99        }
100
101        $this->spacing            = (int) $this->spacing;
102        $this->spacingBeforeFirst = (int) $this->spacingBeforeFirst;
103        $this->spacingAfterLast   = (int) $this->spacingAfterLast;
104
105        if (isset($tokens[$stackPtr]['scope_closer']) === false) {
106            // Must be an interface method, so the closer is the semicolon.
107            $closer = $phpcsFile->findNext(T_SEMICOLON, $stackPtr);
108        } else {
109            $closer = $tokens[$stackPtr]['scope_closer'];
110        }
111
112        $isFirst = false;
113        $isLast  = false;
114
115        $ignore = ([T_WHITESPACE => T_WHITESPACE] + Tokens::$methodPrefixes);
116
117        $prev = $phpcsFile->findPrevious($ignore, ($stackPtr - 1), null, true);
118
119        while ($tokens[$prev]['code'] === T_ATTRIBUTE_END) {
120            // Skip past function attributes.
121            $prev = $phpcsFile->findPrevious($ignore, ($tokens[$prev]['attribute_opener'] - 1), null, true);
122        }
123
124        if ($tokens[$prev]['code'] === T_DOC_COMMENT_CLOSE_TAG) {
125            // Skip past function docblocks.
126            $prev = $phpcsFile->findPrevious($ignore, ($tokens[$prev]['comment_opener'] - 1), null, true);
127        }
128
129        if ($tokens[$prev]['code'] === T_OPEN_CURLY_BRACKET) {
130            $isFirst = true;
131        }
132
133        $next = $phpcsFile->findNext($ignore, ($closer + 1), null, true);
134        if (isset(Tokens::$emptyTokens[$tokens[$next]['code']]) === true
135            && $tokens[$next]['line'] === $tokens[$closer]['line']
136        ) {
137            // Skip past "end" comments.
138            $next = $phpcsFile->findNext($ignore, ($next + 1), null, true);
139        }
140
141        if ($tokens[$next]['code'] === T_CLOSE_CURLY_BRACKET) {
142            $isLast = true;
143        }
144
145        /*
146            Check the number of blank lines
147            after the function.
148        */
149
150        // Allow for comments on the same line as the closer.
151        for ($nextLineToken = ($closer + 1); $nextLineToken < $phpcsFile->numTokens; $nextLineToken++) {
152            if ($tokens[$nextLineToken]['line'] !== $tokens[$closer]['line']) {
153                break;
154            }
155        }
156
157        $requiredSpacing = $this->spacing;
158        $errorCode       = 'After';
159        if ($isLast === true) {
160            $requiredSpacing = $this->spacingAfterLast;
161            $errorCode       = 'AfterLast';
162        }
163
164        $foundLines = 0;
165        if ($nextLineToken === ($phpcsFile->numTokens - 1)) {
166            // We are at the end of the file.
167            // Don't check spacing after the function because this
168            // should be done by an EOF sniff.
169            $foundLines = $requiredSpacing;
170        } else {
171            $nextContent = $phpcsFile->findNext(T_WHITESPACE, $nextLineToken, null, true);
172            if ($nextContent === false) {
173                // We are at the end of the file.
174                // Don't check spacing after the function because this
175                // should be done by an EOF sniff.
176                $foundLines = $requiredSpacing;
177            } else {
178                $foundLines = ($tokens[$nextContent]['line'] - $tokens[$nextLineToken]['line']);
179            }
180        }
181
182        if ($isLast === true) {
183            $phpcsFile->recordMetric($stackPtr, 'Function spacing after last', $foundLines);
184        } else {
185            $phpcsFile->recordMetric($stackPtr, 'Function spacing after', $foundLines);
186        }
187
188        if ($foundLines !== $requiredSpacing) {
189            $error = 'Expected %s blank line';
190            if ($requiredSpacing !== 1) {
191                $error .= 's';
192            }
193
194            $error .= ' after function; %s found';
195            $data   = [
196                $requiredSpacing,
197                $foundLines,
198            ];
199
200            $fix = $phpcsFile->addFixableError($error, $closer, $errorCode, $data);
201            if ($fix === true) {
202                $phpcsFile->fixer->beginChangeset();
203                for ($i = $nextLineToken; $i <= $nextContent; $i++) {
204                    if ($tokens[$i]['line'] === $tokens[$nextContent]['line']) {
205                        $phpcsFile->fixer->addContentBefore($i, str_repeat($phpcsFile->eolChar, $requiredSpacing));
206                        break;
207                    }
208
209                    $phpcsFile->fixer->replaceToken($i, '');
210                }
211
212                $phpcsFile->fixer->endChangeset();
213            }//end if
214        }//end if
215
216        /*
217            Check the number of blank lines
218            before the function.
219        */
220
221        $prevLineToken = null;
222        for ($i = $stackPtr; $i >= 0; $i--) {
223            if ($tokens[$i]['line'] === $tokens[$stackPtr]['line']) {
224                continue;
225            }
226
227            $prevLineToken = $i;
228            break;
229        }
230
231        if ($prevLineToken === null) {
232            // Never found the previous line, which means
233            // there are 0 blank lines before the function.
234            $foundLines    = 0;
235            $prevContent   = 0;
236            $prevLineToken = 0;
237        } else {
238            $currentLine = $tokens[$stackPtr]['line'];
239
240            $prevContent = $phpcsFile->findPrevious(T_WHITESPACE, $prevLineToken, null, true);
241
242            if ($tokens[$prevContent]['code'] === T_COMMENT
243                || isset(Tokens::$phpcsCommentTokens[$tokens[$prevContent]['code']]) === true
244            ) {
245                // Ignore comments as they can have different spacing rules, and this
246                // isn't a proper function comment anyway.
247                return;
248            }
249
250            while ($tokens[$prevContent]['code'] === T_ATTRIBUTE_END
251                && $tokens[$prevContent]['line'] === ($currentLine - 1)
252            ) {
253                // Account for function attributes.
254                $currentLine = $tokens[$tokens[$prevContent]['attribute_opener']]['line'];
255                $prevContent = $phpcsFile->findPrevious(T_WHITESPACE, ($tokens[$prevContent]['attribute_opener'] - 1), null, true);
256            }
257
258            if ($tokens[$prevContent]['code'] === T_DOC_COMMENT_CLOSE_TAG
259                && $tokens[$prevContent]['line'] === ($currentLine - 1)
260            ) {
261                // Account for function comments.
262                $prevContent = $phpcsFile->findPrevious(T_WHITESPACE, ($tokens[$prevContent]['comment_opener'] - 1), null, true);
263            }
264
265            $prevLineToken = $prevContent;
266
267            // Before we throw an error, check that we are not throwing an error
268            // for another function. We don't want to error for no blank lines after
269            // the previous function and no blank lines before this one as well.
270            $prevLine   = ($tokens[$prevContent]['line'] - 1);
271            $i          = ($stackPtr - 1);
272            $foundLines = 0;
273
274            $stopAt = 0;
275            if (isset($tokens[$stackPtr]['conditions']) === true) {
276                $conditions = $tokens[$stackPtr]['conditions'];
277                $conditions = array_keys($conditions);
278                $stopAt     = array_pop($conditions);
279            }
280
281            while ($currentLine !== $prevLine && $currentLine > 1 && $i > $stopAt) {
282                if ($tokens[$i]['code'] === T_FUNCTION) {
283                    // Found another interface or abstract function.
284                    return;
285                }
286
287                if ($tokens[$i]['code'] === T_CLOSE_CURLY_BRACKET
288                    && $tokens[$tokens[$i]['scope_condition']]['code'] === T_FUNCTION
289                ) {
290                    // Found a previous function.
291                    return;
292                }
293
294                $currentLine = $tokens[$i]['line'];
295                if ($currentLine === $prevLine) {
296                    break;
297                }
298
299                if ($tokens[($i - 1)]['line'] < $currentLine && $tokens[($i + 1)]['line'] > $currentLine) {
300                    // This token is on a line by itself. If it is whitespace, the line is empty.
301                    if ($tokens[$i]['code'] === T_WHITESPACE) {
302                        $foundLines++;
303                    }
304                }
305
306                $i--;
307            }//end while
308        }//end if
309
310        $requiredSpacing = $this->spacing;
311        $errorCode       = 'Before';
312        if ($isFirst === true) {
313            $requiredSpacing = $this->spacingBeforeFirst;
314            $errorCode       = 'BeforeFirst';
315
316            $phpcsFile->recordMetric($stackPtr, 'Function spacing before first', $foundLines);
317        } else {
318            $phpcsFile->recordMetric($stackPtr, 'Function spacing before', $foundLines);
319        }
320
321        if ($foundLines !== $requiredSpacing) {
322            $error = 'Expected %s blank line';
323            if ($requiredSpacing !== 1) {
324                $error .= 's';
325            }
326
327            $error .= ' before function; %s found';
328            $data   = [
329                $requiredSpacing,
330                $foundLines,
331            ];
332
333            $fix = $phpcsFile->addFixableError($error, $stackPtr, $errorCode, $data);
334            if ($fix === true) {
335                $nextSpace = $phpcsFile->findNext(T_WHITESPACE, ($prevContent + 1), $stackPtr);
336                if ($nextSpace === false) {
337                    $nextSpace = ($stackPtr - 1);
338                }
339
340                if ($foundLines < $requiredSpacing) {
341                    $padding = str_repeat($phpcsFile->eolChar, ($requiredSpacing - $foundLines));
342                    $phpcsFile->fixer->addContent($prevLineToken, $padding);
343                } else {
344                    $nextContent = $phpcsFile->findNext(T_WHITESPACE, ($nextSpace + 1), null, true);
345                    $phpcsFile->fixer->beginChangeset();
346                    for ($i = $nextSpace; $i < $nextContent; $i++) {
347                        if ($tokens[$i]['line'] === $tokens[$prevContent]['line']) {
348                            continue;
349                        }
350
351                        if ($tokens[$i]['line'] === $tokens[$nextContent]['line']) {
352                            $phpcsFile->fixer->addContentBefore($i, str_repeat($phpcsFile->eolChar, $requiredSpacing));
353                            break;
354                        }
355
356                        $phpcsFile->fixer->replaceToken($i, '');
357                    }
358
359                    $phpcsFile->fixer->endChangeset();
360                }//end if
361            }//end if
362        }//end if
363
364    }//end process()
365
366
367}//end class
368