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

583 lines
19 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 PHPCSUtils\Tokens\Collections;
use PHPCSUtils\Utils\Arrays;
use PHPCSUtils\Utils\PassedParameters;
use WordPressCS\WordPress\Sniff;
/**
* Enforces alignment of the double arrow assignment operator for multi-item, multi-line arrays.
*
* - Align the double arrow operator to the same column for each item in a multi-item array.
* - Allows for setting a maxColumn property to aid in managing line-length.
* - Allows for new line(s) before a double arrow (configurable).
* - Allows for handling multi-line array items differently if so desired (configurable).
*
* @link https://developer.wordpress.org/coding-standards/wordpress-coding-standards/php/#indentation
*
* @since 0.14.0
*
* {@internal This sniff should eventually be pulled upstream as part of a solution
* for https://github.com/squizlabs/PHP_CodeSniffer/issues/582 }}
*/
final class MultipleStatementAlignmentSniff extends Sniff {
/**
* Whether or not to ignore an array item for the purpose of alignment
* when a new line is found between the array key and the double arrow.
*
* @since 0.14.0
*
* @var bool
*/
public $ignoreNewlines = true;
/**
* Whether the alignment should be exact.
*
* Exact in this context means "largest index key + 1 space".
* When `false`, that is seen as the minimum alignment.
*
* @since 0.14.0
*
* @var bool
*/
public $exact = true;
/**
* The maximum column on which the double arrow alignment should be set.
*
* This property allows for limiting the whitespace padding to prevent
* overly long lines.
*
* If this value is set to, for instance, 60, it will:
* - if the expected column < 60, align at the expected column.
* - if the expected column >= 60, align at column 60.
* - for the outliers, i.e. the array indexes where the end position
* goes past column 60, it will not align the arrow, the sniff will
* just make sure there is only one space between the end of the
* array index and the double arrow.
*
* The column value is regarded as a hard value, i.e. includes indentation,
* so setting it very low is not a good idea.
*
* @since 0.14.0
*
* @var int
*/
public $maxColumn = 1000;
/**
* Whether or not to align the arrow operator for multi-line array items.
*
* Whether or not an item is regarded as multi-line is based on the **value**
* of the item, not the key.
*
* Valid values are:
* - 'always': Default. Align all arrays items regardless of single/multi-line.
* - 'never': Never align array items which span multiple lines.
* This will enforce one space between the array index and the
* double arrow operator for multi-line array items, independently
* of the alignment of the rest of the array items.
* Multi-line items where the arrow is already aligned with the
* "expected" alignment, however, will be left alone.
* - operator : Only align the operator for multi-line arrays items if the
* + number percentage of multi-line items passes the comparison.
* - As it is a percentage, the number has to be between 0 and 100.
* - Supported operators: <, <=, >, >=, ==, =, !=, <>
* - The percentage is calculated against all array items
* (with and without assignment operator).
* - The (new) expected alignment will be calculated based only
* on the items being aligned.
* - Multi-line items where the arrow is already aligned with the
* (new) "expected" alignment, however, will be left alone.
* Examples:
* * Setting this to `!=100` or `<100` means that alignment will
* be enforced, unless *all* array items are multi-line.
* This is probably the most commonly desired situation.
* * Setting this to `=100` means that alignment will only
* be enforced, if *all* array items are multi-line.
* * Setting this to `<50` means that the majority of array items
* need to be single line before alignment is enforced for
* multi-line items in the array.
* * Setting this to `=0` is useless as in that case there are
* no multi-line items in the array anyway.
*
* This setting will respect the `ignoreNewlines` and `maxColumnn` settings.
*
* @since 0.14.0
*
* @var string|int
*/
public $alignMultilineItems = 'always';
/**
* Storage for parsed $alignMultilineItems operator part.
*
* @since 0.14.0
*
* @var string
*/
private $operator;
/**
* Storage for parsed $alignMultilineItems numeric part.
*
* Stored as a string as the comparison will be done string based.
*
* @since 0.14.0
*
* @var string
*/
private $number;
/**
* Returns an array of tokens this test wants to listen for.
*
* @since 0.14.0
*
* @return array
*/
public function register() {
return Collections::arrayOpenTokensBC();
}
/**
* Processes this test, when one of its tokens is encountered.
*
* @since 0.14.0
*
* @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( 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'];
$array_items = PassedParameters::getParameters( $this->phpcsFile, $stackPtr );
if ( empty( $array_items ) ) {
return;
}
// Pass off to either the single line or multi-line array analysis.
if ( $this->tokens[ $opener ]['line'] === $this->tokens[ $closer ]['line'] ) {
return $this->process_single_line_array( $stackPtr, $array_items, $opener, $closer );
} else {
return $this->process_multi_line_array( $stackPtr, $array_items, $opener, $closer );
}
}
/**
* Process a single-line array.
*
* While the WP standard does not allow single line multi-item associative arrays,
* this sniff should function independently of that.
*
* The `WordPress.WhiteSpace.OperatorSpacing` sniff already covers checking that
* there is a space between the array key and the double arrow, but doesn't
* enforce it to be exactly one space for single line arrays.
* That is what this method covers.
*
* @since 0.14.0
*
* @param int $stackPtr The position of the current token in the stack.
* @param array $items Info array containing information on each array item.
* @param int $opener The position of the array opener.
* @param int $closer The position of the array closer.
*
* @return int|void Integer stack pointer to skip forward or void to continue
* normal file processing.
*/
protected function process_single_line_array( $stackPtr, $items, $opener, $closer ) {
/*
* For single line arrays, we don't care about what level the arrow is from.
* Just find and fix them all.
*/
$next_arrow = $this->phpcsFile->findNext(
\T_DOUBLE_ARROW,
( $opener + 1 ),
$closer
);
while ( false !== $next_arrow ) {
if ( \T_WHITESPACE === $this->tokens[ ( $next_arrow - 1 ) ]['code'] ) {
$space_length = $this->tokens[ ( $next_arrow - 1 ) ]['length'];
if ( 1 !== $space_length ) {
$error = 'Expected 1 space between "%s" and double arrow; %s found';
$data = array(
$this->tokens[ ( $next_arrow - 2 ) ]['content'],
$space_length,
);
$fix = $this->phpcsFile->addFixableWarning( $error, $next_arrow, 'SpaceBeforeDoubleArrow', $data );
if ( true === $fix ) {
$this->phpcsFile->fixer->replaceToken( ( $next_arrow - 1 ), ' ' );
}
}
}
// Find the position of the next double arrow.
$next_arrow = $this->phpcsFile->findNext(
\T_DOUBLE_ARROW,
( $next_arrow + 1 ),
$closer
);
}
// Ignore any child-arrays as the double arrows in these will already have been handled.
return ( $closer + 1 );
}
/**
* Process a multi-line array.
*
* @since 0.14.0
*
* @param int $stackPtr The position of the current token in the stack.
* @param array $items Info array containing information on each array item.
* @param int $opener The position of the array opener.
* @param int $closer The position of the array closer.
*
* @return void
*/
protected function process_multi_line_array( $stackPtr, $items, $opener, $closer ) {
$this->maxColumn = (int) $this->maxColumn;
$this->validate_align_multiline_items();
/*
* Determine what the spacing before the arrow should be.
*
* Will unset any array items without double arrow and with new line whitespace
* if newlines are to be ignored, so the second foreach loop only has to deal
* with items which need attention.
*
* This sniff does not take incorrect indentation of array keys into account.
* That's for the `WordPress.Arrays.ArrayIndentation` sniff to fix.
* If that would affect the alignment, a second (or third) loop of the fixer
* will correct it (again) after the indentation has been fixed.
*/
$index_end_cols = array(); // Keep track of the end column position of index keys.
$double_arrow_cols = array(); // Keep track of arrow column position and count.
$multi_line_count = 0;
$total_items = \count( $items );
foreach ( $items as $key => $item ) {
// Find the double arrow if there is one.
$double_arrow = Arrays::getDoubleArrowPtr( $this->phpcsFile, $item['start'], $item['end'] );
if ( false === $double_arrow ) {
unset( $items[ $key ] );
continue;
}
// Find the end of the array key.
$last_index_token = $this->phpcsFile->findPrevious(
\T_WHITESPACE,
( $double_arrow - 1 ),
$item['start'],
true
);
if ( true === $this->ignoreNewlines
&& $this->tokens[ $last_index_token ]['line'] !== $this->tokens[ $double_arrow ]['line']
) {
// Ignore this item as it has a new line between the item key and the double arrow.
unset( $items[ $key ] );
continue;
}
$index_end_position = ( $this->tokens[ $last_index_token ]['column'] + ( $this->tokens[ $last_index_token ]['length'] - 1 ) );
$items[ $key ]['operatorPtr'] = $double_arrow;
$items[ $key ]['last_index_token'] = $last_index_token;
$items[ $key ]['last_index_col'] = $index_end_position;
if ( $this->tokens[ $last_index_token ]['line'] === $this->tokens[ $item['end'] ]['line'] ) {
$items[ $key ]['single_line'] = true;
} else {
$items[ $key ]['single_line'] = false;
++$multi_line_count;
}
if ( ( $index_end_position + 2 ) <= $this->maxColumn ) {
$index_end_cols[] = $index_end_position;
}
if ( ! isset( $double_arrow_cols[ $this->tokens[ $double_arrow ]['column'] ] ) ) {
$double_arrow_cols[ $this->tokens[ $double_arrow ]['column'] ] = 1;
} else {
++$double_arrow_cols[ $this->tokens[ $double_arrow ]['column'] ];
}
}
unset( $key, $item, $double_arrow, $last_index_token );
if ( empty( $items ) || empty( $index_end_cols ) ) {
// No actionable array items found.
return;
}
/*
* Determine whether the operators for multi-line items should be aligned.
*/
if ( 'always' === $this->alignMultilineItems ) {
$alignMultilineItems = true;
} elseif ( 'never' === $this->alignMultilineItems ) {
$alignMultilineItems = false;
} else {
$percentage = (string) round( ( $multi_line_count / $total_items ) * 100, 0 );
// Bit hacky, but this is the only comparison function in PHP which allows to
// pass the comparison operator. And hey, it works ;-).
$alignMultilineItems = version_compare( $percentage, $this->number, $this->operator );
}
/*
* If necessary, rebuild the $index_end_cols and $double_arrow_cols arrays
* excluding multi-line items.
*/
if ( false === $alignMultilineItems ) {
$select_index_end_cols = array();
$double_arrow_cols = array();
foreach ( $items as $item ) {
if ( false === $item['single_line'] ) {
continue;
}
if ( ( $item['last_index_col'] + 2 ) <= $this->maxColumn ) {
$select_index_end_cols[] = $item['last_index_col'];
}
if ( ! isset( $double_arrow_cols[ $this->tokens[ $item['operatorPtr'] ]['column'] ] ) ) {
$double_arrow_cols[ $this->tokens[ $item['operatorPtr'] ]['column'] ] = 1;
} else {
++$double_arrow_cols[ $this->tokens[ $item['operatorPtr'] ]['column'] ];
}
}
}
/*
* Determine the expected position of the double arrows.
*/
if ( ! empty( $select_index_end_cols ) ) {
$max_index_width = max( $select_index_end_cols );
} else {
$max_index_width = max( $index_end_cols );
}
$expected_col = ( $max_index_width + 2 );
if ( false === $this->exact && ! empty( $double_arrow_cols ) ) {
/*
* If the alignment does not have to be exact, see if a majority
* group of the arrows is already at an acceptable position.
*/
arsort( $double_arrow_cols, \SORT_NUMERIC );
reset( $double_arrow_cols );
$count = current( $double_arrow_cols );
if ( $count > 1 || ( 1 === $count && \count( $items ) === 1 ) ) {
// Allow for several groups of arrows having the same $count.
$filtered_double_arrow_cols = array_keys( $double_arrow_cols, $count, true );
foreach ( $filtered_double_arrow_cols as $col ) {
if ( $col > $expected_col && $col <= $this->maxColumn ) {
$expected_col = $col;
break;
}
}
}
}
unset( $max_index_width, $count, $filtered_double_arrow_cols, $col );
/*
* Verify and correct the spacing around the double arrows.
*/
foreach ( $items as $item ) {
if ( $this->tokens[ $item['operatorPtr'] ]['column'] === $expected_col
&& $this->tokens[ $item['operatorPtr'] ]['line'] === $this->tokens[ $item['last_index_token'] ]['line']
) {
// Already correctly aligned.
continue;
}
if ( \T_WHITESPACE !== $this->tokens[ ( $item['operatorPtr'] - 1 ) ]['code'] ) {
$before = 0;
} elseif ( $this->tokens[ $item['last_index_token'] ]['line'] !== $this->tokens[ $item['operatorPtr'] ]['line'] ) {
$before = 'newline';
} else {
$before = $this->tokens[ ( $item['operatorPtr'] - 1 ) ]['length'];
}
/*
* Deal with index sizes larger than maxColumn and with multi-line
* array items which should not be aligned.
*/
if ( ( $item['last_index_col'] + 2 ) > $this->maxColumn
|| ( false === $alignMultilineItems && false === $item['single_line'] )
) {
if ( ( $item['last_index_col'] + 2 ) === $this->tokens[ $item['operatorPtr'] ]['column']
&& $this->tokens[ $item['operatorPtr'] ]['line'] === $this->tokens[ $item['last_index_token'] ]['line']
) {
// MaxColumn/Multi-line item exception, already correctly aligned.
continue;
}
$prefix = 'LongIndex';
if ( false === $alignMultilineItems && false === $item['single_line'] ) {
$prefix = 'MultilineItem';
}
$error_code = $prefix . 'SpaceBeforeDoubleArrow';
if ( 0 === $before ) {
$error_code = $prefix . 'NoSpaceBeforeDoubleArrow';
}
$fix = $this->phpcsFile->addFixableWarning(
'Expected 1 space between "%s" and double arrow; %s found.',
$item['operatorPtr'],
$error_code,
array(
$this->tokens[ $item['last_index_token'] ]['content'],
$before,
)
);
if ( true === $fix ) {
$this->phpcsFile->fixer->beginChangeset();
// Remove whitespace tokens between the end of the index and the arrow, if any.
for ( $i = ( $item['last_index_token'] + 1 ); $i < $item['operatorPtr']; $i++ ) {
$this->phpcsFile->fixer->replaceToken( $i, '' );
}
// Add the correct whitespace.
$this->phpcsFile->fixer->addContent( $item['last_index_token'], ' ' );
$this->phpcsFile->fixer->endChangeset();
}
continue;
}
/*
* Deal with the space before double arrows in all other cases.
*/
$expected_whitespace = $expected_col - ( $this->tokens[ $item['last_index_token'] ]['column'] + $this->tokens[ $item['last_index_token'] ]['length'] );
$fix = $this->phpcsFile->addFixableWarning(
'Array double arrow not aligned correctly; expected %s space(s) between "%s" and double arrow, but found %s.',
$item['operatorPtr'],
'DoubleArrowNotAligned',
array(
$expected_whitespace,
$this->tokens[ $item['last_index_token'] ]['content'],
$before,
)
);
if ( true === $fix ) {
if ( 0 === $before || 'newline' === $before ) {
$this->phpcsFile->fixer->beginChangeset();
// Remove whitespace tokens between the end of the index and the arrow, if any.
for ( $i = ( $item['last_index_token'] + 1 ); $i < $item['operatorPtr']; $i++ ) {
$this->phpcsFile->fixer->replaceToken( $i, '' );
}
// Add the correct whitespace.
$this->phpcsFile->fixer->addContent(
$item['last_index_token'],
str_repeat( ' ', $expected_whitespace )
);
$this->phpcsFile->fixer->endChangeset();
} elseif ( $expected_whitespace > $before ) {
// Add to the existing whitespace to prevent replacing tabs with spaces.
// That's the concern of another sniff.
$this->phpcsFile->fixer->addContent(
( $item['operatorPtr'] - 1 ),
str_repeat( ' ', ( $expected_whitespace - $before ) )
);
} else {
// Too much whitespace found.
$this->phpcsFile->fixer->replaceToken(
( $item['operatorPtr'] - 1 ),
str_repeat( ' ', $expected_whitespace )
);
}
}
}
}
/**
* Validate that a valid value has been received for the alignMultilineItems property.
*
* This message may be thrown more than once if the property is being changed inline in a file.
*
* @since 0.14.0
*
* @return void
*/
protected function validate_align_multiline_items() {
$alignMultilineItems = $this->alignMultilineItems;
if ( 'always' === $alignMultilineItems || 'never' === $alignMultilineItems ) {
return;
} else {
// Correct for a potentially added % sign.
$alignMultilineItems = rtrim( $alignMultilineItems, '%' );
if ( preg_match( '`^([=<>!]{1,2})(100|[0-9]{1,2})$`', $alignMultilineItems, $matches ) > 0 ) {
$operator = $matches[1];
$number = (int) $matches[2];
if ( \in_array( $operator, array( '<', '<=', '>', '>=', '==', '=', '!=', '<>' ), true ) === true
&& ( $number >= 0 && $number <= 100 )
) {
$this->alignMultilineItems = $alignMultilineItems;
$this->number = (string) $number;
$this->operator = $operator;
return;
}
}
}
$this->phpcsFile->addError(
'Invalid property value passed: "%s". The value for the "alignMultilineItems" property for the "WordPress.Arrays.MultipleStatementAlignment" sniff should be either "always", "never" or an comparison operator + a number between 0 and 100.',
0,
'InvalidPropertyPassed',
array( $this->alignMultilineItems )
);
// Reset to the default if an invalid value was received.
$this->alignMultilineItems = 'always';
}
}