wishthis/vendor/wp-coding-standards/wpcs/WordPress/Sniffs/Arrays/ArrayIndentationSniff.php
2023-09-20 13:52:46 +02:00

550 lines
17 KiB
PHP

<?php
/**
* WordPress Coding Standard.
*
* @package WPCS\WordPressCodingStandards
* @link https://github.com/WordPress/WordPress-Coding-Standards
* @license https://opensource.org/licenses/MIT MIT
*/
namespace WordPressCS\WordPress\Sniffs\Arrays;
use PHP_CodeSniffer\Util\Tokens;
use PHPCSUtils\BackCompat\Helper;
use PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Arrays;
use PHPCSUtils\Utils\PassedParameters;
use WordPressCS\WordPress\Sniff;
/**
* Enforces WordPress array indentation for multi-line arrays.
*
* @link https://developer.wordpress.org/coding-standards/wordpress-coding-standards/php/#indentation
*
* @since 0.12.0
* @since 0.13.0 Class name changed: this class is now namespaced.
*
* {@internal This sniff should eventually be pulled upstream as part of a solution
* for https://github.com/squizlabs/PHP_CodeSniffer/issues/582 }}
*/
final class ArrayIndentationSniff extends Sniff {
/**
* Should tabs be used for indenting?
*
* If TRUE, fixes will be made using tabs instead of spaces.
* The size of each tab is important, so it should be specified
* using the --tab-width CLI argument.
*
* {@internal While for WPCS this should always be `true`, this property
* was added in anticipation of upstreaming the sniff.
* This property is the same as used in `Generic.WhiteSpace.ScopeIndent`.}}
*
* @var bool
*/
public $tabIndent = true;
/**
* The --tab-width CLI value that is being used.
*
* @var int
*/
private $tab_width;
/**
* Tokens to ignore for subsequent lines in a multi-line array item.
*
* Property is set in the register() method.
*
* @var array
*/
private $ignore_tokens = array();
/**
* Returns an array of tokens this test wants to listen for.
*
* @return array
*/
public function register() {
/*
* Set the $ignore_tokens property.
*
* Existing heredoc, nowdoc and inline HTML indentation should be respected at all times.
*/
$this->ignore_tokens = Tokens::$heredocTokens;
unset( $this->ignore_tokens[ \T_START_HEREDOC ], $this->ignore_tokens[ \T_START_NOWDOC ] );
$this->ignore_tokens[ \T_INLINE_HTML ] = \T_INLINE_HTML;
return Collections::arrayOpenTokensBC();
}
/**
* Processes this test, when one of its tokens is encountered.
*
* @param int $stackPtr The position of the current token in the stack.
*
* @return int|void Integer stack pointer to skip forward or void to continue
* normal file processing.
*/
public function process_token( $stackPtr ) {
if ( ! isset( $this->tab_width ) ) {
$this->tab_width = Helper::getTabWidth( $this->phpcsFile );
}
if ( isset( Collections::shortArrayListOpenTokensBC()[ $this->tokens[ $stackPtr ]['code'] ] )
&& Arrays::isShortArray( $this->phpcsFile, $stackPtr ) === false
) {
// Short list, not short array.
return;
}
/*
* Determine the array opener & closer.
*/
$array_open_close = Arrays::getOpenClose( $this->phpcsFile, $stackPtr );
if ( false === $array_open_close ) {
// Array open/close could not be determined.
return;
}
$opener = $array_open_close['opener'];
$closer = $array_open_close['closer'];
if ( $this->tokens[ $opener ]['line'] === $this->tokens[ $closer ]['line'] ) {
// Not interested in single line arrays.
return;
}
/*
* Check the closing bracket is lined up with the start of the content on the line
* containing the array opener.
*/
$opener_line_spaces = $this->get_indentation_size( $opener );
$closer_line_spaces = ( $this->tokens[ $closer ]['column'] - 1 );
if ( $closer_line_spaces !== $opener_line_spaces ) {
$error = 'Array closer not aligned correctly; expected %s space(s) but found %s';
$error_code = 'CloseBraceNotAligned';
/*
* Report & fix the issue if the close brace is on its own line with
* nothing or only indentation whitespace before it.
*/
if ( 0 === $closer_line_spaces
|| ( \T_WHITESPACE === $this->tokens[ ( $closer - 1 ) ]['code']
&& 1 === $this->tokens[ ( $closer - 1 ) ]['column'] )
) {
$this->add_array_alignment_error(
$closer,
$error,
$error_code,
$opener_line_spaces,
$closer_line_spaces,
$this->get_indentation_string( $opener_line_spaces )
);
} else {
/*
* Otherwise, only report the error, don't try and fix it (yet).
*
* It will get corrected in a future loop of the fixer once the closer
* has been moved to its own line by the `ArrayDeclarationSpacing` sniff.
*/
$this->phpcsFile->addError(
$error,
$closer,
$error_code,
array( $opener_line_spaces, $closer_line_spaces )
);
}
unset( $error, $error_code );
}
/*
* Verify & correct the array item indentation.
*/
$array_items = PassedParameters::getParameters( $this->phpcsFile, $stackPtr );
if ( empty( $array_items ) ) {
// Strange, no array items found.
return;
}
$expected_spaces = ( $opener_line_spaces + $this->tab_width );
$expected_indent = $this->get_indentation_string( $expected_spaces );
$end_of_previous_item = $opener;
foreach ( $array_items as $item ) {
$end_of_this_item = ( $item['end'] + 1 );
// Find the line on which the item starts.
$first_content = $this->phpcsFile->findNext(
array( \T_WHITESPACE, \T_DOC_COMMENT_WHITESPACE ),
$item['start'],
$end_of_this_item,
true
);
// Deal with trailing comments.
if ( false !== $first_content
&& \T_COMMENT === $this->tokens[ $first_content ]['code']
&& $this->tokens[ $first_content ]['line'] === $this->tokens[ $end_of_previous_item ]['line']
) {
$first_content = $this->phpcsFile->findNext(
array( \T_WHITESPACE, \T_DOC_COMMENT_WHITESPACE, \T_COMMENT ),
( $first_content + 1 ),
$end_of_this_item,
true
);
}
if ( false === $first_content ) {
$end_of_previous_item = $end_of_this_item;
continue;
}
// Bow out from reporting and fixing mixed multi-line/single-line arrays.
// That is handled by the ArrayDeclarationSpacingSniff.
if ( $this->tokens[ $first_content ]['line'] === $this->tokens[ $end_of_previous_item ]['line'] ) {
return $closer;
}
// Ignore this item if there is anything but whitespace before the start of the next item.
if ( 1 !== $this->tokens[ $first_content ]['column'] ) {
// Go to the start of the line.
$i = $first_content;
while ( 1 !== $this->tokens[ --$i ]['column'] );
if ( \T_WHITESPACE !== $this->tokens[ $i ]['code'] ) {
$end_of_previous_item = $end_of_this_item;
continue;
}
}
$found_spaces = ( $this->tokens[ $first_content ]['column'] - 1 );
if ( $found_spaces !== $expected_spaces ) {
$this->add_array_alignment_error(
$first_content,
'Array item not aligned correctly; expected %s spaces but found %s',
'ItemNotAligned',
$expected_spaces,
$found_spaces,
$expected_indent
);
}
// No need for further checking if this is a one-line array item.
if ( $this->tokens[ $first_content ]['line'] === $this->tokens[ $item['end'] ]['line'] ) {
$end_of_previous_item = $end_of_this_item;
continue;
}
/*
* Multi-line array items.
*
* Verify & if needed, correct the indentation of subsequent lines.
* Subsequent lines may be indented more or less than the mimimum expected indent,
* but the "first line after" should be indented - at least - as much as the very first line
* of the array item.
* Indentation correction for subsequent lines will be based on that diff.
*/
// Find first token on second line of the array item.
// If the second line is a heredoc/nowdoc, continue on until we find a line with a different token.
// Same for the second line of a multi-line text string.
for ( $ptr = ( $first_content + 1 ); $ptr <= $item['end']; $ptr++ ) {
if ( $this->tokens[ $first_content ]['line'] !== $this->tokens[ $ptr ]['line']
&& 1 === $this->tokens[ $ptr ]['column']
&& false === $this->ignore_token( $ptr )
) {
break;
}
}
$first_content_on_line2 = $this->phpcsFile->findNext(
array( \T_WHITESPACE, \T_DOC_COMMENT_WHITESPACE ),
$ptr,
$end_of_this_item,
true
);
if ( false === $first_content_on_line2 ) {
/*
* Apparently there were only tokens in the ignore list on subsequent lines.
*
* In that case, the comma after the array item might be on a line by itself,
* so check its placement.
*/
if ( $this->tokens[ $item['end'] ]['line'] !== $this->tokens[ $end_of_this_item ]['line']
&& \T_COMMA === $this->tokens[ $end_of_this_item ]['code']
&& ( $this->tokens[ $end_of_this_item ]['column'] - 1 ) !== $expected_spaces
) {
$this->add_array_alignment_error(
$end_of_this_item,
'Comma after multi-line array item not aligned correctly; expected %s spaces, but found %s',
'MultiLineArrayItemCommaNotAligned',
$expected_spaces,
( $this->tokens[ $end_of_this_item ]['column'] - 1 ),
$expected_indent
);
}
$end_of_previous_item = $end_of_this_item;
continue;
}
$found_spaces_on_line2 = $this->get_indentation_size( $first_content_on_line2 );
$expected_spaces_on_line2 = $expected_spaces;
if ( $found_spaces < $found_spaces_on_line2 ) {
$expected_spaces_on_line2 += ( $found_spaces_on_line2 - $found_spaces );
}
if ( $found_spaces_on_line2 !== $expected_spaces_on_line2 ) {
$fix = $this->phpcsFile->addFixableError(
'Multi-line array item not aligned correctly; expected %s spaces, but found %s',
$first_content_on_line2,
'MultiLineArrayItemNotAligned',
array(
$expected_spaces_on_line2,
$found_spaces_on_line2,
)
);
if ( true === $fix ) {
$expected_indent_on_line2 = $this->get_indentation_string( $expected_spaces_on_line2 );
$this->phpcsFile->fixer->beginChangeset();
// Fix second line for the array item.
if ( 1 === $this->tokens[ $first_content_on_line2 ]['column']
&& \T_COMMENT === $this->tokens[ $first_content_on_line2 ]['code']
) {
$actual_comment = ltrim( $this->tokens[ $first_content_on_line2 ]['content'] );
$replacement = $expected_indent_on_line2 . $actual_comment;
$this->phpcsFile->fixer->replaceToken( $first_content_on_line2, $replacement );
} else {
$this->fix_alignment_error( $first_content_on_line2, $expected_indent_on_line2 );
}
// Fix subsequent lines.
for ( $i = ( $first_content_on_line2 + 1 ); $i <= $item['end']; $i++ ) {
// We're only interested in the first token on each line.
if ( 1 !== $this->tokens[ $i ]['column'] ) {
if ( $this->tokens[ $i ]['line'] === $this->tokens[ $item['end'] ]['line'] ) {
// We might as well quit if we're past the first token on the last line.
break;
}
continue;
}
$first_content_on_line = $this->phpcsFile->findNext(
array( \T_WHITESPACE, \T_DOC_COMMENT_WHITESPACE ),
$i,
$end_of_this_item,
true
);
if ( false === $first_content_on_line ) {
break;
}
// Ignore lines with heredoc and nowdoc tokens and subsequent lines in multi-line strings.
if ( true === $this->ignore_token( $first_content_on_line ) ) {
$i = $first_content_on_line;
continue;
}
$found_spaces_on_line = $this->get_indentation_size( $first_content_on_line );
$expected_spaces_on_line = ( $expected_spaces_on_line2 + ( $found_spaces_on_line - $found_spaces_on_line2 ) );
$expected_spaces_on_line = max( $expected_spaces_on_line, 0 ); // Can't be below 0.
$expected_indent_on_line = $this->get_indentation_string( $expected_spaces_on_line );
if ( $found_spaces_on_line !== $expected_spaces_on_line ) {
if ( 1 === $this->tokens[ $first_content_on_line ]['column']
&& \T_COMMENT === $this->tokens[ $first_content_on_line ]['code']
) {
$actual_comment = ltrim( $this->tokens[ $first_content_on_line ]['content'] );
$replacement = $expected_indent_on_line . $actual_comment;
$this->phpcsFile->fixer->replaceToken( $first_content_on_line, $replacement );
} else {
$this->fix_alignment_error( $first_content_on_line, $expected_indent_on_line );
}
}
// Move past any potential empty lines between the previous non-empty line and this one.
// No need to do the fixes twice.
$i = $first_content_on_line;
}
/*
* Check the placement of the comma after the array item as it might be on a line by itself.
*/
if ( $this->tokens[ $item['end'] ]['line'] !== $this->tokens[ $end_of_this_item ]['line']
&& \T_COMMA === $this->tokens[ $end_of_this_item ]['code']
&& ( $this->tokens[ $end_of_this_item ]['column'] - 1 ) !== $expected_spaces
) {
$this->add_array_alignment_error(
$end_of_this_item,
'Comma after array item not aligned correctly; expected %s spaces, but found %s',
'MultiLineArrayItemCommaNotAligned',
$expected_spaces,
( $this->tokens[ $end_of_this_item ]['column'] - 1 ),
$expected_indent
);
}
$this->phpcsFile->fixer->endChangeset();
}
}
$end_of_previous_item = $end_of_this_item;
}
}
/**
* Should the token be ignored ?
*
* This method is only intended to be used with the first token on a line
* for subsequent lines in an multi-line array item.
*
* @param int $ptr Stack pointer to the first token on a line.
*
* @return bool
*/
protected function ignore_token( $ptr ) {
$token_code = $this->tokens[ $ptr ]['code'];
if ( isset( $this->ignore_tokens[ $token_code ] ) ) {
return true;
}
/*
* If it's a subsequent line of a multi-line sting, it will not start with a quote
* character, nor just *be* a quote character.
*/
if ( isset( Tokens::$stringTokens[ $token_code ] ) === true ) {
// Deal with closing quote of a multi-line string being on its own line.
if ( "'" === $this->tokens[ $ptr ]['content']
|| '"' === $this->tokens[ $ptr ]['content']
) {
return true;
}
// Deal with subsequent lines of a multi-line string where the token is broken up per line.
if ( "'" !== $this->tokens[ $ptr ]['content'][0]
&& '"' !== $this->tokens[ $ptr ]['content'][0]
) {
return true;
}
}
return false;
}
/**
* Determine the line indentation whitespace.
*
* @param int $ptr Stack pointer to an arbitrary token on a line.
*
* @return int Nr of spaces found. Where necessary, tabs are translated to spaces.
*/
protected function get_indentation_size( $ptr ) {
// Find the first token on the line.
for ( ; $ptr >= 0; $ptr-- ) {
if ( 1 === $this->tokens[ $ptr ]['column'] ) {
break;
}
}
$whitespace = '';
if ( \T_WHITESPACE === $this->tokens[ $ptr ]['code']
|| \T_DOC_COMMENT_WHITESPACE === $this->tokens[ $ptr ]['code']
) {
return $this->tokens[ $ptr ]['length'];
}
/*
* Special case for multi-line, non-docblock comments.
* Only applicable for subsequent lines in an array item.
*
* First/Single line is tokenized as T_WHITESPACE + T_COMMENT
* Subsequent lines are tokenized as T_COMMENT including the indentation whitespace.
*/
if ( \T_COMMENT === $this->tokens[ $ptr ]['code'] ) {
$content = $this->tokens[ $ptr ]['content'];
$actual_comment = ltrim( $content );
$whitespace = str_replace( $actual_comment, '', $content );
}
return \strlen( $whitespace );
}
/**
* Create an indentation string.
*
* @param int $nr Number of spaces the indentation should be.
*
* @return string
*/
protected function get_indentation_string( $nr ) {
if ( 0 >= $nr ) {
return '';
}
// Space-based indentation.
if ( false === $this->tabIndent ) {
return str_repeat( ' ', $nr );
}
// Tab-based indentation.
$num_tabs = (int) floor( $nr / $this->tab_width );
$remaining = ( $nr % $this->tab_width );
$tab_indent = str_repeat( "\t", $num_tabs );
$tab_indent .= str_repeat( ' ', $remaining );
return $tab_indent;
}
/**
* Throw an error and fix incorrect array alignment.
*
* @param int $ptr Stack pointer to the first content on the line.
* @param string $error Error message.
* @param string $error_code Error code.
* @param int $expected Expected nr of spaces (tabs translated to space value).
* @param int $found Found nr of spaces (tabs translated to space value).
* @param string $new_indent Whitespace indent replacement content.
*
* @return void
*/
protected function add_array_alignment_error( $ptr, $error, $error_code, $expected, $found, $new_indent ) {
$fix = $this->phpcsFile->addFixableError( $error, $ptr, $error_code, array( $expected, $found ) );
if ( true === $fix ) {
$this->fix_alignment_error( $ptr, $new_indent );
}
}
/**
* Fix incorrect array alignment.
*
* @param int $ptr Stack pointer to the first content on the line.
* @param string $new_indent Whitespace indent replacement content.
*
* @return void
*/
protected function fix_alignment_error( $ptr, $new_indent ) {
if ( 1 === $this->tokens[ $ptr ]['column'] ) {
$this->phpcsFile->fixer->addContentBefore( $ptr, $new_indent );
} else {
$this->phpcsFile->fixer->replaceToken( ( $ptr - 1 ), $new_indent );
}
}
}