445 lines
17 KiB
PHP
445 lines
17 KiB
PHP
<?php
|
|
/**
|
|
* PHPCSExtra, a collection of sniffs and standards for use with PHP_CodeSniffer.
|
|
*
|
|
* @package PHPCSExtra
|
|
* @copyright 2020 PHPCSExtra Contributors
|
|
* @license https://opensource.org/licenses/LGPL-3.0 LGPL3
|
|
* @link https://github.com/PHPCSStandards/PHPCSExtra
|
|
*/
|
|
|
|
namespace PHPCSExtra\Universal\Sniffs\WhiteSpace;
|
|
|
|
use PHP_CodeSniffer\Files\File;
|
|
use PHP_CodeSniffer\Sniffs\Sniff;
|
|
use PHP_CodeSniffer\Util\Tokens;
|
|
use PHPCSUtils\BackCompat\Helper;
|
|
use PHPCSUtils\Tokens\Collections;
|
|
|
|
/**
|
|
* Detects when the indentation is not a multiple of a tab-width, i.e. when precision alignment is used.
|
|
*
|
|
* In rare cases, spaces for precision alignment can be intentional and acceptable,
|
|
* but more often than not, precision alignment is a typo.
|
|
*
|
|
* Notes:
|
|
* - When using this sniff with tab-based standards, please ensure that the `tab-width` is set
|
|
* and either don't set the `$indent` property or set it to the tab-width.
|
|
* - Precision alignment *within* text strings (multi-line text strings, heredocs, nowdocs)
|
|
* will NOT be flagged by this sniff.
|
|
* - The fixer works based on "best guess" and may not always result in the desired indentation.
|
|
* - This fixer will use tabs or spaces based on whether tabs were present in the original indent.
|
|
* Use the PHPCS native `Generic.WhiteSpace.DisallowTabIndent` or the
|
|
* `Generic.WhiteSpace.DisallowSpaceIndent` sniff to clean up the results if so desired.
|
|
*
|
|
* @since 1.0.0
|
|
*/
|
|
final class PrecisionAlignmentSniff implements Sniff
|
|
{
|
|
|
|
/**
|
|
* A list of tokenizers this sniff supports.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var string[]
|
|
*/
|
|
public $supportedTokenizers = [
|
|
'PHP',
|
|
'JS',
|
|
'CSS',
|
|
];
|
|
|
|
/**
|
|
* The indent used for the codebase.
|
|
*
|
|
* This property is used to determine whether something is indentation or precision alignment.
|
|
* If this property is not set, the sniff will look to the `--tab-width` CLI value.
|
|
* If that also isn't set, the default tab-width of 4 will be used.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var int|null
|
|
*/
|
|
public $indent = null;
|
|
|
|
/**
|
|
* Allow for providing a list of tokens for which (preceding) precision alignment should be ignored.
|
|
*
|
|
* By default, precision alignment will always be flagged.
|
|
*
|
|
* Example usage:
|
|
* ```xml
|
|
* <rule ref="Universal.WhiteSpace.PrecisionAlignment">
|
|
* <properties>
|
|
* <property name="ignoreAlignmentBefore" type="array">
|
|
* <!-- Ignore precision alignment in inline HTML -->
|
|
* <element value="T_INLINE_HTML"/>
|
|
* <!-- Ignore precision alignment in multiline chained method calls. -->
|
|
* <element value="T_OBJECT_OPERATOR"/>
|
|
* </property>
|
|
* </properties>
|
|
* </rule>
|
|
* ```
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var string[]
|
|
*/
|
|
public $ignoreAlignmentBefore = [];
|
|
|
|
/**
|
|
* Whether or not potential trailing whitespace on otherwise blank lines should be examined or ignored.
|
|
*
|
|
* Defaults to `true`, i.e. ignore blank lines.
|
|
*
|
|
* It is recommended to only set this to `false` if the standard including this sniff does not
|
|
* include the `Squiz.WhiteSpace.SuperfluousWhitespace` sniff (which is included in most standards).
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var bool
|
|
*/
|
|
public $ignoreBlankLines = true;
|
|
|
|
/**
|
|
* The --tab-width CLI value that is being used.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var int
|
|
*/
|
|
private $tabWidth;
|
|
|
|
/**
|
|
* Whitespace tokens and tokens which can contain leading whitespace.
|
|
*
|
|
* A few additional tokens will be added to this list in the register() method.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var array<int|string, int|string>
|
|
*/
|
|
private $tokensToCheck = [
|
|
\T_WHITESPACE => \T_WHITESPACE,
|
|
\T_INLINE_HTML => \T_INLINE_HTML,
|
|
\T_DOC_COMMENT_WHITESPACE => \T_DOC_COMMENT_WHITESPACE,
|
|
\T_COMMENT => \T_COMMENT,
|
|
\T_END_HEREDOC => \T_END_HEREDOC,
|
|
\T_END_NOWDOC => \T_END_NOWDOC,
|
|
];
|
|
|
|
/**
|
|
* Returns an array of tokens this test wants to listen for.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @return array<int|string>
|
|
*/
|
|
public function register()
|
|
{
|
|
// Add the ignore annotation tokens to the list of tokens to check.
|
|
$this->tokensToCheck += Tokens::$phpcsCommentTokens;
|
|
|
|
return Collections::phpOpenTags();
|
|
}
|
|
|
|
/**
|
|
* Processes this test, when one of its tokens is encountered.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @param \PHP_CodeSniffer\Files\File $phpcsFile The file being scanned.
|
|
* @param int $stackPtr The position of the current token
|
|
* in the stack passed in $tokens.
|
|
*
|
|
* @return int Integer stack pointer to skip the rest of the file.
|
|
*/
|
|
public function process(File $phpcsFile, $stackPtr)
|
|
{
|
|
/*
|
|
* Handle the properties.
|
|
*/
|
|
if (isset($this->tabWidth) === false || \defined('PHP_CODESNIFFER_IN_TESTS') === true) {
|
|
$this->tabWidth = Helper::getTabWidth($phpcsFile);
|
|
}
|
|
|
|
if (isset($this->indent) === true) {
|
|
$indent = (int) $this->indent;
|
|
} else {
|
|
$indent = $this->tabWidth;
|
|
}
|
|
|
|
$ignoreTokens = (array) $this->ignoreAlignmentBefore;
|
|
if (empty($ignoreTokens) === false) {
|
|
$ignoreTokens = \array_flip($ignoreTokens);
|
|
}
|
|
|
|
/*
|
|
* Check the whole file in one go.
|
|
*/
|
|
$tokens = $phpcsFile->getTokens();
|
|
|
|
for ($i = 0; $i < $phpcsFile->numTokens; $i++) {
|
|
if ($tokens[$i]['column'] !== 1) {
|
|
// Only interested in the first token on each line.
|
|
continue;
|
|
}
|
|
|
|
if (isset($this->tokensToCheck[$tokens[$i]['code']]) === false) {
|
|
// Not one of the target tokens.
|
|
continue;
|
|
}
|
|
|
|
if ($tokens[$i]['content'] === $phpcsFile->eolChar) {
|
|
// Skip completely blank lines.
|
|
continue;
|
|
}
|
|
|
|
if (isset($ignoreTokens[$tokens[$i]['type']]) === true
|
|
|| (isset($tokens[($i + 1)]) && isset($ignoreTokens[$tokens[($i + 1)]['type']]))
|
|
) {
|
|
// This is one of the tokens being ignored.
|
|
continue;
|
|
}
|
|
|
|
$origContent = null;
|
|
if (isset($tokens[$i]['orig_content']) === true) {
|
|
$origContent = $tokens[$i]['orig_content'];
|
|
}
|
|
|
|
$spaces = 0;
|
|
$length = 0;
|
|
$content = '';
|
|
$closer = '';
|
|
|
|
switch ($tokens[$i]['code']) {
|
|
case \T_WHITESPACE:
|
|
if ($this->ignoreBlankLines === true
|
|
&& isset($tokens[($i + 1)])
|
|
&& $tokens[$i]['line'] !== $tokens[($i + 1)]['line']
|
|
) {
|
|
// Skip blank lines which only contain trailing whitespace.
|
|
continue 2;
|
|
}
|
|
|
|
$spaces = ($tokens[$i]['length'] % $indent);
|
|
break;
|
|
|
|
case \T_DOC_COMMENT_WHITESPACE:
|
|
/*
|
|
* Blank lines with trailing whitespace in docblocks are tokenized as
|
|
* two T_DOC_COMMENT_WHITESPACE tokens: one for the trailing whitespace,
|
|
* one for the new line character.
|
|
*/
|
|
if ($this->ignoreBlankLines === true
|
|
&& isset($tokens[($i + 1)])
|
|
&& $tokens[($i + 1)]['content'] === $phpcsFile->eolChar
|
|
&& isset($tokens[($i + 2)])
|
|
&& $tokens[$i]['line'] !== $tokens[($i + 2)]['line']
|
|
) {
|
|
// Skip blank lines which only contain trailing whitespace.
|
|
continue 2;
|
|
}
|
|
|
|
$spaces = ($tokens[$i]['length'] % $indent);
|
|
|
|
if (isset($tokens[($i + 1)]) === true
|
|
&& ($tokens[($i + 1)]['code'] === \T_DOC_COMMENT_STAR
|
|
|| $tokens[($i + 1)]['code'] === \T_DOC_COMMENT_CLOSE_TAG)
|
|
&& $spaces !== 0
|
|
) {
|
|
// One alignment space expected before the *.
|
|
--$spaces;
|
|
}
|
|
break;
|
|
|
|
case \T_COMMENT:
|
|
case \T_INLINE_HTML:
|
|
if ($this->ignoreBlankLines === true
|
|
&& \trim($tokens[$i]['content']) === ''
|
|
&& isset($tokens[($i + 1)])
|
|
&& $tokens[$i]['line'] !== $tokens[($i + 1)]['line']
|
|
) {
|
|
// Skip blank lines which only contain trailing whitespace.
|
|
continue 2;
|
|
}
|
|
|
|
// Deliberate fall-through.
|
|
|
|
case \T_PHPCS_ENABLE:
|
|
case \T_PHPCS_DISABLE:
|
|
case \T_PHPCS_SET:
|
|
case \T_PHPCS_IGNORE:
|
|
case \T_PHPCS_IGNORE_FILE:
|
|
/*
|
|
* Indentation is included in the contents of the token for:
|
|
* - inline HTML
|
|
* - PHP 7.3+ flexible heredoc/nowdoc closer identifiers (see below);
|
|
* - subsequent lines of multi-line comments;
|
|
* - PHPCS native annotations when part of a multi-line comment.
|
|
*/
|
|
$content = \ltrim($tokens[$i]['content']);
|
|
$whitespace = \str_replace($content, '', $tokens[$i]['content']);
|
|
|
|
/*
|
|
* If there is no content, this is a blank line in a comment or in inline HTML.
|
|
* In that case, use the predetermined length as otherwise the new line character
|
|
* at the end of the whitespace will throw the count off.
|
|
*/
|
|
$length = ($content === '') ? $tokens[$i]['length'] : \strlen($whitespace);
|
|
$spaces = ($length % $indent);
|
|
|
|
/*
|
|
* For multi-line star-comments, which use (aligned) stars on subsequent
|
|
* lines, we don't want to trigger on the one extra space before the star.
|
|
*
|
|
* While not 100% correct, don't exclude inline HTML from this check as
|
|
* otherwise the sniff would trigger on multi-line /*-style inline javascript comments.
|
|
* This may cause false negatives as there is no check for being in a
|
|
* <script> tag, but that will be rare.
|
|
*/
|
|
if (isset($content[0]) === true && $content[0] === '*' && $spaces !== 0) {
|
|
--$spaces;
|
|
}
|
|
break;
|
|
|
|
case \T_END_HEREDOC:
|
|
case \T_END_NOWDOC:
|
|
/*
|
|
* PHPCS does not execute tab replacement in heredoc/nowdoc closer
|
|
* tokens prior to PHPCS 3.7.2, so handle this ourselves.
|
|
*/
|
|
$content = $tokens[$i]['content'];
|
|
if (\strpos($tokens[$i]['content'], "\t") !== false) {
|
|
$origContent = $content;
|
|
$content = \str_replace("\t", \str_repeat(' ', $this->tabWidth), $content);
|
|
}
|
|
|
|
$closer = \ltrim($content);
|
|
$whitespace = \str_replace($closer, '', $content);
|
|
$length = \strlen($whitespace);
|
|
$spaces = ($length % $indent);
|
|
break;
|
|
}
|
|
|
|
if ($spaces === 0) {
|
|
continue;
|
|
}
|
|
|
|
$fix = $phpcsFile->addFixableWarning(
|
|
'Found precision alignment of %s spaces.',
|
|
$i,
|
|
'Found',
|
|
[$spaces]
|
|
);
|
|
|
|
if ($fix === true) {
|
|
if ($tokens[$i]['code'] === \T_END_HEREDOC || $tokens[$i]['code'] === \T_END_NOWDOC) {
|
|
// For heredoc/nowdoc, always round down to prevent introducing parse errors.
|
|
$tabstops = (int) \floor($spaces / $indent);
|
|
} else {
|
|
// For everything else, use "best guess".
|
|
$tabstops = (int) \round($spaces / $indent, 0);
|
|
}
|
|
|
|
switch ($tokens[$i]['code']) {
|
|
case \T_WHITESPACE:
|
|
/*
|
|
* More complex than you'd think as "length" doesn't include new lines,
|
|
* but we don't want to remove new lines either.
|
|
*/
|
|
$replaceLength = (((int) ($tokens[$i]['length'] / $indent) + $tabstops) * $indent);
|
|
$replace = $this->getReplacement($replaceLength, $origContent);
|
|
$newContent = \substr_replace($tokens[$i]['content'], $replace, 0, $tokens[$i]['length']);
|
|
|
|
$phpcsFile->fixer->replaceToken($i, $newContent);
|
|
break;
|
|
|
|
case \T_DOC_COMMENT_WHITESPACE:
|
|
$replaceLength = (((int) ($tokens[$i]['length'] / $indent) + $tabstops) * $indent);
|
|
$replace = $this->getReplacement($replaceLength, $origContent);
|
|
|
|
if (isset($tokens[($i + 1)]) === true
|
|
&& ($tokens[($i + 1)]['code'] === \T_DOC_COMMENT_STAR
|
|
|| $tokens[($i + 1)]['code'] === \T_DOC_COMMENT_CLOSE_TAG)
|
|
&& $tabstops === 0
|
|
) {
|
|
// Maintain the extra space before the star.
|
|
$replace .= ' ';
|
|
}
|
|
|
|
$newContent = \substr_replace($tokens[$i]['content'], $replace, 0, $tokens[$i]['length']);
|
|
|
|
$phpcsFile->fixer->replaceToken($i, $newContent);
|
|
break;
|
|
|
|
case \T_COMMENT:
|
|
case \T_INLINE_HTML:
|
|
case \T_PHPCS_ENABLE:
|
|
case \T_PHPCS_DISABLE:
|
|
case \T_PHPCS_SET:
|
|
case \T_PHPCS_IGNORE:
|
|
case \T_PHPCS_IGNORE_FILE:
|
|
$replaceLength = (((int) ($length / $indent) + $tabstops) * $indent);
|
|
$replace = $this->getReplacement($replaceLength, $origContent);
|
|
|
|
if (isset($content[0]) === true && $content[0] === '*' && $tabstops === 0) {
|
|
// Maintain the extra space before the star.
|
|
$replace .= ' ';
|
|
}
|
|
|
|
if ($content === '') {
|
|
// Preserve new lines in blank line comment tokens.
|
|
$newContent = \substr_replace($tokens[$i]['content'], $replace, 0, $length);
|
|
} else {
|
|
$newContent = $replace . $content;
|
|
}
|
|
|
|
$phpcsFile->fixer->replaceToken($i, $newContent);
|
|
break;
|
|
|
|
case \T_END_HEREDOC:
|
|
case \T_END_NOWDOC:
|
|
$replaceLength = (((int) ($length / $indent) + $tabstops) * $indent);
|
|
$replace = $this->getReplacement($replaceLength, $origContent);
|
|
|
|
$phpcsFile->fixer->replaceToken($i, $replace . $closer);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// No need to look at this file again.
|
|
return $phpcsFile->numTokens;
|
|
}
|
|
|
|
/**
|
|
* Get the whitespace replacement. Respect tabs vs spaces.
|
|
*
|
|
* @param int $length The target length of the replacement.
|
|
* @param string|null $origContent The original token content without tabs replaced (if available).
|
|
*
|
|
* @return string
|
|
*/
|
|
private function getReplacement($length, $origContent)
|
|
{
|
|
if ($origContent !== null) {
|
|
// Check whether tabs were part of the indent or inline alignment.
|
|
$content = \ltrim($origContent);
|
|
$whitespace = $origContent;
|
|
if ($content !== '') {
|
|
$whitespace = \str_replace($content, '', $origContent);
|
|
}
|
|
|
|
if (\strpos($whitespace, "\t") !== false) {
|
|
// Original indent used tabs. Use tabs in replacement too.
|
|
$tabs = (int) ($length / $this->tabWidth);
|
|
$spaces = $length % $this->tabWidth;
|
|
return \str_repeat("\t", $tabs) . \str_repeat(' ', (int) $spaces);
|
|
}
|
|
}
|
|
|
|
return \str_repeat(' ', $length);
|
|
}
|
|
}
|