<?php /** * Checks the format of the file header. * * @author Greg Sherwood <gsherwood@squiz.net> * @copyright 2006-2019 Squiz Pty Ltd (ABN 77 084 670 600) * @license https://github.com/squizlabs/PHP_CodeSniffer/blob/master/licence.txt BSD Licence */ namespace PHP_CodeSniffer\Standards\PSR12\Sniffs\Files; use PHP_CodeSniffer\Files\File; use PHP_CodeSniffer\Sniffs\Sniff; use PHP_CodeSniffer\Util\Tokens; class FileHeaderSniff implements Sniff { /** * Returns an array of tokens this test wants to listen for. * * @return array */ public function register() { return [T_OPEN_TAG]; }//end register() /** * Processes this sniff when one of its tokens is encountered. * * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. * @param int $stackPtr The position of the current * token in the stack. * * @return int|null */ public function process(File $phpcsFile, $stackPtr) { $tokens = $phpcsFile->getTokens(); $possibleHeaders = []; $searchFor = Tokens::$ooScopeTokens; $searchFor[T_OPEN_TAG] = T_OPEN_TAG; $openTag = $stackPtr; do { $headerLines = $this->getHeaderLines($phpcsFile, $openTag); if (empty($headerLines) === true && $openTag === $stackPtr) { // No content in the file. return; } $possibleHeaders[$openTag] = $headerLines; if (count($headerLines) > 1) { break; } $next = $phpcsFile->findNext($searchFor, ($openTag + 1)); if (isset(Tokens::$ooScopeTokens[$tokens[$next]['code']]) === true) { // Once we find an OO token, the file content has // definitely started. break; } $openTag = $next; } while ($openTag !== false); if ($openTag === false) { // We never found a proper file header. // If the file has multiple PHP open tags, we know // that it must be a mix of PHP and HTML (or similar) // so the header rules do not apply. if (count($possibleHeaders) > 1) { return $phpcsFile->numTokens; } // There is only one possible header. // If it is the first content in the file, it technically // serves as the file header, and the open tag needs to // have a newline after it. Otherwise, ignore it. if ($stackPtr > 0) { return $phpcsFile->numTokens; } $openTag = $stackPtr; } else if (count($possibleHeaders) > 1) { // There are other PHP blocks before the file header. $error = 'The file header must be the first content in the file'; $phpcsFile->addError($error, $openTag, 'HeaderPosition'); } else { // The first possible header was the file header block, // so make sure it is the first content in the file. if ($openTag !== 0) { // Allow for hashbang lines. $hashbang = false; if ($tokens[($openTag - 1)]['code'] === T_INLINE_HTML) { $content = trim($tokens[($openTag - 1)]['content']); if (substr($content, 0, 2) === '#!') { $hashbang = true; } } if ($hashbang === false) { $error = 'The file header must be the first content in the file'; $phpcsFile->addError($error, $openTag, 'HeaderPosition'); } } }//end if $this->processHeaderLines($phpcsFile, $possibleHeaders[$openTag]); return $phpcsFile->numTokens; }//end process() /** * Gather information about the statements inside a possible file header. * * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. * @param int $stackPtr The position of the current * token in the stack. * * @return array */ public function getHeaderLines(File $phpcsFile, $stackPtr) { $tokens = $phpcsFile->getTokens(); $next = $phpcsFile->findNext(T_WHITESPACE, ($stackPtr + 1), null, true); if ($next === false) { return []; } $headerLines = []; $headerLines[] = [ 'type' => 'tag', 'start' => $stackPtr, 'end' => $stackPtr, ]; $foundDocblock = false; $commentOpeners = Tokens::$scopeOpeners; unset($commentOpeners[T_NAMESPACE]); unset($commentOpeners[T_DECLARE]); unset($commentOpeners[T_USE]); unset($commentOpeners[T_IF]); unset($commentOpeners[T_WHILE]); unset($commentOpeners[T_FOR]); unset($commentOpeners[T_FOREACH]); unset($commentOpeners[T_DO]); unset($commentOpeners[T_TRY]); do { switch ($tokens[$next]['code']) { case T_DOC_COMMENT_OPEN_TAG: if ($foundDocblock === true) { // Found a second docblock, so start of code. break(2); } // Make sure this is not a code-level docblock. $end = $tokens[$next]['comment_closer']; for ($docToken = ($end + 1); $docToken < $phpcsFile->numTokens; $docToken++) { if (isset(Tokens::$emptyTokens[$tokens[$docToken]['code']]) === true) { continue; } if ($tokens[$docToken]['code'] === T_ATTRIBUTE && isset($tokens[$docToken]['attribute_closer']) === true ) { $docToken = $tokens[$docToken]['attribute_closer']; continue; } break; } if ($docToken === $phpcsFile->numTokens) { $docToken--; } if (isset($commentOpeners[$tokens[$docToken]['code']]) === false && isset(Tokens::$methodPrefixes[$tokens[$docToken]['code']]) === false ) { // Check for an @var annotation. $annotation = false; for ($i = $next; $i < $end; $i++) { if ($tokens[$i]['code'] === T_DOC_COMMENT_TAG && strtolower($tokens[$i]['content']) === '@var' ) { $annotation = true; break; } } if ($annotation === false) { $foundDocblock = true; $headerLines[] = [ 'type' => 'docblock', 'start' => $next, 'end' => $end, ]; } }//end if $next = $end; break; case T_DECLARE: case T_NAMESPACE: if (isset($tokens[$next]['scope_opener']) === true) { // If this statement is using bracketed syntax, it doesn't // apply to the entire files and so is not part of header. // The header has now ended and the main code block begins. break(2); } $end = $phpcsFile->findEndOfStatement($next); $headerLines[] = [ 'type' => substr(strtolower($tokens[$next]['type']), 2), 'start' => $next, 'end' => $end, ]; $next = $end; break; case T_USE: $type = 'use'; $useType = $phpcsFile->findNext(Tokens::$emptyTokens, ($next + 1), null, true); if ($useType !== false && $tokens[$useType]['code'] === T_STRING) { $content = strtolower($tokens[$useType]['content']); if ($content === 'function' || $content === 'const') { $type .= ' '.$content; } } $end = $phpcsFile->findEndOfStatement($next); $headerLines[] = [ 'type' => $type, 'start' => $next, 'end' => $end, ]; $next = $end; break; default: // Skip comments as PSR-12 doesn't say if these are allowed or not. if (isset(Tokens::$commentTokens[$tokens[$next]['code']]) === true) { $next = $phpcsFile->findNext(Tokens::$commentTokens, ($next + 1), null, true); if ($next === false) { // We reached the end of the file. break(2); } $next--; break; } // We found the start of the main code block. break(2); }//end switch $next = $phpcsFile->findNext(T_WHITESPACE, ($next + 1), null, true); } while ($next !== false); return $headerLines; }//end getHeaderLines() /** * Check the spacing and grouping of the statements inside each header block. * * @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned. * @param array $headerLines Header information, as sourced * from getHeaderLines(). * * @return int|null */ public function processHeaderLines(File $phpcsFile, $headerLines) { $tokens = $phpcsFile->getTokens(); $found = []; foreach ($headerLines as $i => $line) { if (isset($headerLines[($i + 1)]) === false || $headerLines[($i + 1)]['type'] !== $line['type'] ) { // We're at the end of the current header block. // Make sure there is a single blank line after // this block. $next = $phpcsFile->findNext(T_WHITESPACE, ($line['end'] + 1), null, true); if ($next !== false && $tokens[$next]['line'] !== ($tokens[$line['end']]['line'] + 2)) { $error = 'Header blocks must be separated by a single blank line'; $fix = $phpcsFile->addFixableError($error, $line['end'], 'SpacingAfterBlock'); if ($fix === true) { if ($tokens[$next]['line'] === $tokens[$line['end']]['line']) { $phpcsFile->fixer->addContentBefore($next, $phpcsFile->eolChar.$phpcsFile->eolChar); } else if ($tokens[$next]['line'] === ($tokens[$line['end']]['line'] + 1)) { $phpcsFile->fixer->addNewline($line['end']); } else { $phpcsFile->fixer->beginChangeset(); for ($i = ($line['end'] + 1); $i < $next; $i++) { if ($tokens[$i]['line'] === ($tokens[$line['end']]['line'] + 2)) { break; } $phpcsFile->fixer->replaceToken($i, ''); } $phpcsFile->fixer->endChangeset(); } }//end if }//end if // Make sure we haven't seen this next block before. if (isset($headerLines[($i + 1)]) === true && isset($found[$headerLines[($i + 1)]['type']]) === true ) { $error = 'Similar statements must be grouped together inside header blocks; '; $error .= 'the first "%s" statement was found on line %s'; $data = [ $headerLines[($i + 1)]['type'], $tokens[$found[$headerLines[($i + 1)]['type']]['start']]['line'], ]; $phpcsFile->addError($error, $headerLines[($i + 1)]['start'], 'IncorrectGrouping', $data); } } else if ($headerLines[($i + 1)]['type'] === $line['type']) { // Still in the same block, so make sure there is no // blank line after this statement. $next = $phpcsFile->findNext(T_WHITESPACE, ($line['end'] + 1), null, true); if ($tokens[$next]['line'] > ($tokens[$line['end']]['line'] + 1)) { $error = 'Header blocks must not contain blank lines'; $fix = $phpcsFile->addFixableError($error, $line['end'], 'SpacingInsideBlock'); if ($fix === true) { $phpcsFile->fixer->beginChangeset(); for ($i = ($line['end'] + 1); $i < $next; $i++) { if ($tokens[$i]['line'] === $tokens[$line['end']]['line']) { continue; } if ($tokens[$i]['line'] === $tokens[$next]['line']) { break; } $phpcsFile->fixer->replaceToken($i, ''); } $phpcsFile->fixer->endChangeset(); } } }//end if if (isset($found[$line['type']]) === false) { $found[$line['type']] = $line; } }//end foreach /* Next, check that the order of the header blocks is correct: Opening php tag. File-level docblock. One or more declare statements. The namespace declaration of the file. One or more class-based use import statements. One or more function-based use import statements. One or more constant-based use import statements. */ $blockOrder = [ 'tag' => 'opening PHP tag', 'docblock' => 'file-level docblock', 'declare' => 'declare statements', 'namespace' => 'namespace declaration', 'use' => 'class-based use imports', 'use function' => 'function-based use imports', 'use const' => 'constant-based use imports', ]; foreach (array_keys($found) as $type) { if ($type === 'tag') { // The opening tag is always in the correct spot. continue; } do { $orderedType = next($blockOrder); } while ($orderedType !== false && key($blockOrder) !== $type); if ($orderedType === false) { // We didn't find the block type in the rest of the // ordered array, so it is out of place. // Error and reset the array to the correct position // so we can check the next block. reset($blockOrder); $prevValidType = 'tag'; do { $orderedType = next($blockOrder); if (isset($found[key($blockOrder)]) === true && key($blockOrder) !== $type ) { $prevValidType = key($blockOrder); } } while ($orderedType !== false && key($blockOrder) !== $type); $error = 'The %s must follow the %s in the file header'; $data = [ $blockOrder[$type], $blockOrder[$prevValidType], ]; $phpcsFile->addError($error, $found[$type]['start'], 'IncorrectOrder', $data); }//end if }//end foreach }//end processHeaderLines() }//end class