297 lines
9.8 KiB
PHP
297 lines
9.8 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\Arrays;
|
|
|
|
use PHP_CodeSniffer\Files\File;
|
|
use PHP_CodeSniffer\Util\Tokens;
|
|
use PHPCSUtils\AbstractSniffs\AbstractArrayDeclarationSniff;
|
|
use PHPCSUtils\BackCompat\Helper;
|
|
|
|
/**
|
|
* Detect duplicate array keys in array declarations.
|
|
*
|
|
* This sniff will detect duplicate keys with high precision, though any array key
|
|
* set via a variable/constant/function call is excluded from the examination.
|
|
*
|
|
* The sniff will handle the change in how numeric array keys are set
|
|
* since PHP 8.0 and will flag keys which would be duplicates cross-version.
|
|
* {@link https://wiki.php.net/rfc/negative_array_index}
|
|
*
|
|
* @since 1.0.0
|
|
*/
|
|
final class DuplicateArrayKeySniff extends AbstractArrayDeclarationSniff
|
|
{
|
|
|
|
/**
|
|
* Keep track of which array keys have been seen already on PHP < 8.0.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var array<int, array<string, int>>
|
|
*/
|
|
private $keysSeenLt8 = [];
|
|
|
|
/**
|
|
* Keep track of which array keys have been seen already on PHP >= 8.0.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var array<int, array<string, int>>
|
|
*/
|
|
private $keysSeenGt8 = [];
|
|
|
|
/**
|
|
* Keep track of the maximum seen integer key to know what the next value will be for
|
|
* array items without a key on PHP < 8.0.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var int
|
|
*/
|
|
private $currentMaxIntKeyLt8;
|
|
|
|
/**
|
|
* Keep track of the maximum seen integer key to know what the next value will be for
|
|
* array items without a key on PHP >= 8.0.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var int
|
|
*/
|
|
private $currentMaxIntKeyGt8;
|
|
|
|
/**
|
|
* PHP version as configured or -1 if unknown.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @var int
|
|
*/
|
|
private $phpVersion;
|
|
|
|
/**
|
|
* Process every part of the array declaration.
|
|
*
|
|
* This contains the default logic for the sniff, but can be overloaded in a concrete child class
|
|
* if needed.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
|
|
* token was found.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function processArray(File $phpcsFile)
|
|
{
|
|
// Reset properties before processing this array.
|
|
$this->keysSeenLt8 = [];
|
|
$this->keysSeenGt8 = [];
|
|
|
|
if (isset($this->phpVersion) === false) {
|
|
// Set default value to prevent this code from running every time the sniff is triggered.
|
|
$this->phpVersion = -1;
|
|
|
|
$phpVersion = Helper::getConfigData('php_version');
|
|
if ($phpVersion !== null) {
|
|
$this->phpVersion = (int) $phpVersion;
|
|
}
|
|
}
|
|
|
|
unset($this->currentMaxIntKeyLt8, $this->currentMaxIntKeyGt8);
|
|
|
|
parent::processArray($phpcsFile);
|
|
}
|
|
|
|
/**
|
|
* Process the tokens in an array key.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
|
|
* token was found.
|
|
* @param int $startPtr The stack pointer to the first token in the "key" part of
|
|
* an array item.
|
|
* @param int $endPtr The stack pointer to the last token in the "key" part of
|
|
* an array item.
|
|
* @param int $itemNr Which item in the array is being handled.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function processKey(File $phpcsFile, $startPtr, $endPtr, $itemNr)
|
|
{
|
|
$key = $this->getActualArrayKey($phpcsFile, $startPtr, $endPtr);
|
|
|
|
if (isset($key) === false) {
|
|
// Key could not be determined.
|
|
return;
|
|
}
|
|
|
|
$integerKey = \is_int($key);
|
|
|
|
$errorMsg = 'Duplicate array key found. The value will be overwritten%s.'
|
|
. ' The %s array key "%s" was first seen on line %d';
|
|
$errorCode = 'Found';
|
|
$errors = [];
|
|
$baseData = [
|
|
($integerKey === true) ? 'integer' : 'string',
|
|
$key,
|
|
];
|
|
|
|
/*
|
|
* Check if we've seen the key before.
|
|
*/
|
|
if (($this->phpVersion === -1 || $this->phpVersion < 80000)
|
|
&& isset($this->keysSeenLt8[$key]) === true
|
|
) {
|
|
$errors['phplt8'] = [
|
|
'data_subset' => $baseData,
|
|
'error_suffix' => '',
|
|
'code_suffix' => '',
|
|
];
|
|
|
|
if ($integerKey === true) {
|
|
$errors['phplt8']['error_suffix'] = ' when using PHP < 8.0';
|
|
$errors['phplt8']['code_suffix'] = 'ForPHPlt80';
|
|
}
|
|
|
|
$firstSeen = $this->keysSeenLt8[$key];
|
|
$firstNonEmptyFirstSeen = $phpcsFile->findNext(Tokens::$emptyTokens, $firstSeen['ptr'], null, true);
|
|
|
|
$errors['phplt8']['data_subset'][] = $this->tokens[$firstNonEmptyFirstSeen]['line'];
|
|
}
|
|
|
|
if (($this->phpVersion === -1 || $this->phpVersion >= 80000)
|
|
&& isset($this->keysSeenGt8[$key]) === true
|
|
) {
|
|
$errors['phpgt8'] = [
|
|
'data_subset' => $baseData,
|
|
'error_suffix' => '',
|
|
'code_suffix' => '',
|
|
];
|
|
|
|
if ($integerKey === true) {
|
|
$errors['phpgt8']['error_suffix'] = ' when using PHP >= 8.0';
|
|
$errors['phpgt8']['code_suffix'] = 'ForPHPgte80';
|
|
}
|
|
|
|
$firstSeen = $this->keysSeenGt8[$key];
|
|
$firstNonEmptyFirstSeen = $phpcsFile->findNext(Tokens::$emptyTokens, $firstSeen['ptr'], null, true);
|
|
|
|
$errors['phpgt8']['data_subset'][] = $this->tokens[$firstNonEmptyFirstSeen]['line'];
|
|
}
|
|
|
|
/*
|
|
* Throw the error(s).
|
|
*
|
|
* If no PHP version was passed, throw errors both for PHP < 8.0 and PHP >= 8.0.
|
|
* If a PHP version was set, only throw the error appropriate for the selected PHP version.
|
|
* If both errors would effectively be the same, only throw one.
|
|
*/
|
|
if ($errors !== []) {
|
|
$firstNonEmpty = $phpcsFile->findNext(Tokens::$emptyTokens, $startPtr, null, true);
|
|
|
|
if (isset($errors['phplt8'], $errors['phpgt8'])
|
|
&& $errors['phplt8']['data_subset'] === $errors['phpgt8']['data_subset']
|
|
) {
|
|
// Only throw the error once if it would be the same for PHP < 8.0 and PHP >= 8.0.
|
|
$data = $errors['phplt8']['data_subset'];
|
|
\array_unshift($data, '');
|
|
|
|
$phpcsFile->addError($errorMsg, $firstNonEmpty, $errorCode, $data);
|
|
return;
|
|
}
|
|
|
|
if (isset($errors['phplt8'])) {
|
|
$code = $errorCode . $errors['phplt8']['code_suffix'];
|
|
$data = $errors['phplt8']['data_subset'];
|
|
\array_unshift($data, $errors['phplt8']['error_suffix']);
|
|
|
|
$phpcsFile->addError($errorMsg, $firstNonEmpty, $code, $data);
|
|
}
|
|
|
|
if (isset($errors['phpgt8'])) {
|
|
$code = $errorCode . $errors['phpgt8']['code_suffix'];
|
|
$data = $errors['phpgt8']['data_subset'];
|
|
\array_unshift($data, $errors['phpgt8']['error_suffix']);
|
|
|
|
$phpcsFile->addError($errorMsg, $firstNonEmpty, $code, $data);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
/*
|
|
* Key not seen before. Add to arrays.
|
|
*/
|
|
$this->keysSeenLt8[$key] = [
|
|
'item' => $itemNr,
|
|
'ptr' => $startPtr,
|
|
];
|
|
$this->keysSeenGt8[$key] = [
|
|
'item' => $itemNr,
|
|
'ptr' => $startPtr,
|
|
];
|
|
|
|
if ($integerKey === true) {
|
|
if ((isset($this->currentMaxIntKeyLt8) === false && $key > -1)
|
|
|| (isset($this->currentMaxIntKeyLt8) === true && $key > $this->currentMaxIntKeyLt8)
|
|
) {
|
|
$this->currentMaxIntKeyLt8 = $key;
|
|
}
|
|
|
|
if (isset($this->currentMaxIntKeyGt8) === false
|
|
|| $key > $this->currentMaxIntKeyGt8
|
|
) {
|
|
$this->currentMaxIntKeyGt8 = $key;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Process an array item without an array key.
|
|
*
|
|
* @since 1.0.0
|
|
*
|
|
* @param \PHP_CodeSniffer\Files\File $phpcsFile The PHP_CodeSniffer file where the
|
|
* token was found.
|
|
* @param int $startPtr The stack pointer to the first token in the array item,
|
|
* which in this case will be the first token of the array
|
|
* value part of the array item.
|
|
* @param int $itemNr Which item in the array is being handled.
|
|
*
|
|
* @return void
|
|
*/
|
|
public function processNoKey(File $phpcsFile, $startPtr, $itemNr)
|
|
{
|
|
// Track the key for PHP < 8.0.
|
|
if (isset($this->currentMaxIntKeyLt8) === false) {
|
|
$this->currentMaxIntKeyLt8 = -1;
|
|
}
|
|
|
|
++$this->currentMaxIntKeyLt8;
|
|
$this->keysSeenLt8[$this->currentMaxIntKeyLt8] = [
|
|
'item' => $itemNr,
|
|
'ptr' => $startPtr,
|
|
];
|
|
|
|
// Track the key for PHP 8.0+.
|
|
if (isset($this->currentMaxIntKeyGt8) === false) {
|
|
$this->currentMaxIntKeyGt8 = -1;
|
|
}
|
|
|
|
++$this->currentMaxIntKeyGt8;
|
|
$this->keysSeenGt8[$this->currentMaxIntKeyGt8] = [
|
|
'item' => $itemNr,
|
|
'ptr' => $startPtr,
|
|
];
|
|
}
|
|
}
|