Skip to content

Commit

Permalink
Add NoDuplicatedArrayKeyFixer (#196)
Browse files Browse the repository at this point in the history
  • Loading branch information
kubawerlos committed Mar 11, 2020
1 parent cf4b50e commit b1cbe9b
Show file tree
Hide file tree
Showing 9 changed files with 686 additions and 1 deletion.
1 change: 1 addition & 0 deletions CHANGELOG.md
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]
- Add CommentedOutFunctionFixer
- Add NoDuplicatedArrayKeyFixer

## v2.0.0 - *2020-03-01*
- Drop PHP 7.1 support
Expand Down
13 changes: 12 additions & 1 deletion README.md
Expand Up @@ -9,7 +9,7 @@
[![Travis CI build status](https://img.shields.io/travis/kubawerlos/php-cs-fixer-custom-fixers/master.svg?label=Travis+CI)](https://travis-ci.org/kubawerlos/php-cs-fixer-custom-fixers)
[![AppVeyor build status](https://img.shields.io/appveyor/ci/kubawerlos/php-cs-fixer-custom-fixers/master?label=AppVeyor)](https://ci.appveyor.com/project/kubawerlos/php-cs-fixer-custom-fixers)
[![Code coverage](https://img.shields.io/coveralls/github/kubawerlos/php-cs-fixer-custom-fixers/master.svg)](https://coveralls.io/github/kubawerlos/php-cs-fixer-custom-fixers?branch=master)
![Tests](https://img.shields.io/badge/tests-1993-brightgreen.svg)
![Tests](https://img.shields.io/badge/tests-2054-brightgreen.svg)
[![Mutation testing badge](https://badge.stryker-mutator.io/github.com/kubawerlos/php-cs-fixer-custom-fixers/master)](https://stryker-mutator.github.io)
[![Psalm type coverage](https://shepherd.dev/github/kubawerlos/php-cs-fixer-custom-fixers/coverage.svg)](https://shepherd.dev/github/kubawerlos/php-cs-fixer-custom-fixers)

Expand Down Expand Up @@ -153,6 +153,17 @@ There must be no comment generated by Doctrine Migrations.
}
```

#### NoDuplicatedArrayKeyFixer
Duplicated array keys must be removed.
```diff
<?php
$x = [
- "foo" => 1,
"bar" => 2,
"foo" => 3,
];
```

#### NoDuplicatedImportsFixer
Duplicated `use` statements must be removed.
```diff
Expand Down
1 change: 1 addition & 0 deletions dev-tools/psalm.xml
Expand Up @@ -37,6 +37,7 @@
<PossiblyUnusedMethod>
<errorLevel type='suppress'>
<file name='./src/Fixer/OrderedClassElementsInternalFixer.php' />
<file name='../src/Analyzer/Analysis/ArrayElementAnalysis.php' />
<file name='../src/Analyzer/Analysis/SwitchAnalysis.php' />
<file name='../src/Fixer/DeprecatingFixerInterface.php' />
</errorLevel>
Expand Down
51 changes: 51 additions & 0 deletions src/Analyzer/Analysis/ArrayElementAnalysis.php
@@ -0,0 +1,51 @@
<?php

declare(strict_types = 1);

namespace PhpCsFixerCustomFixers\Analyzer\Analysis;

/**
* @internal
*/
final class ArrayElementAnalysis
{
/** @var ?int */
private $keyStartIndex;

/** @var ?int */
private $keyEndIndex;

/** @var int */
private $valueStartIndex;

/** @var int */
private $valueEndIndex;

public function __construct(?int $keyStartIndex, ?int $keyEndIndex, int $valueStartIndex, int $valueEndIndex)
{
$this->keyStartIndex = $keyStartIndex;
$this->keyEndIndex = $keyEndIndex;
$this->valueStartIndex = $valueStartIndex;
$this->valueEndIndex = $valueEndIndex;
}

public function getKeyStartIndex(): ?int
{
return $this->keyStartIndex;
}

public function getKeyEndIndex(): ?int
{
return $this->keyEndIndex;
}

public function getValueStartIndex(): int
{
return $this->valueStartIndex;
}

public function getValueEndIndex(): int
{
return $this->valueEndIndex;
}
}
119 changes: 119 additions & 0 deletions src/Analyzer/ArrayAnalyzer.php
@@ -0,0 +1,119 @@
<?php

declare(strict_types = 1);

namespace PhpCsFixerCustomFixers\Analyzer;

use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixerCustomFixers\Analyzer\Analysis\ArrayElementAnalysis;

/**
* @internal
*/
final class ArrayAnalyzer
{
/**
* @return ArrayElementAnalysis[]
*/
public function getElements(Tokens $tokens, int $index): array
{
if ($tokens[$index]->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_OPEN)) {
/** @var int $arrayContentStartIndex */
$arrayContentStartIndex = $tokens->getNextMeaningfulToken($index);

/** @var int $arrayContentEndIndex */
$arrayContentEndIndex = $tokens->getPrevMeaningfulToken($tokens->findBlockEnd(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $index));

return $this->getElementsForArrayContent($tokens, $arrayContentStartIndex, $arrayContentEndIndex);
}

if ($tokens[$index]->isGivenKind(T_ARRAY)) {
/** @var int $arrayOpenBraceIndex */
$arrayOpenBraceIndex = $tokens->getNextTokenOfKind($index, ['(']);

/** @var int $arrayContentStartIndex */
$arrayContentStartIndex = $tokens->getNextMeaningfulToken($arrayOpenBraceIndex);

/** @var int $arrayContentEndIndex */
$arrayContentEndIndex = $tokens->getPrevMeaningfulToken($tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $arrayOpenBraceIndex));

return $this->getElementsForArrayContent($tokens, $arrayContentStartIndex, $arrayContentEndIndex);
}

throw new \InvalidArgumentException(\sprintf('Index %d is not an array.', $index));
}

/**
* @return ArrayElementAnalysis[]
*/
private function getElementsForArrayContent(Tokens $tokens, int $startIndex, int $endIndex): array
{
$elements = [];

$index = $startIndex;
while ($endIndex >= $index = $this->nextCandidateIndex($tokens, $index)) {
if (!$tokens[$index]->equals(',')) {
continue;
}

/** @var int $elementEndIndex */
$elementEndIndex = $tokens->getPrevMeaningfulToken($index);

$elements[] = $this->createArrayElementAnalysis($tokens, $startIndex, $elementEndIndex);

/** @var int $startIndex */
$startIndex = $tokens->getNextMeaningfulToken($index);
}

if ($startIndex <= $endIndex) {
$elements[] = $this->createArrayElementAnalysis($tokens, $startIndex, $endIndex);
}

return $elements;
}

private function createArrayElementAnalysis(Tokens $tokens, int $startIndex, int $endIndex): ArrayElementAnalysis
{
$index = $startIndex;
while ($endIndex > $index = $this->nextCandidateIndex($tokens, $index)) {
if (!$tokens[$index]->isGivenKind(T_DOUBLE_ARROW)) {
continue;
}

/** @var int $keyEndIndex */
$keyEndIndex = $tokens->getPrevMeaningfulToken($index);

/** @var int $valueStartIndex */
$valueStartIndex = $tokens->getNextMeaningfulToken($index);

return new ArrayElementAnalysis($startIndex, $keyEndIndex, $valueStartIndex, $endIndex);
}

return new ArrayElementAnalysis(null, null, $startIndex, $endIndex);
}

private function nextCandidateIndex(Tokens $tokens, int $index): int
{
if ($tokens[$index]->equals('{')) {
$index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_CURLY_BRACE, $index);
}

if ($tokens[$index]->equals('(')) {
$index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $index);
}

if ($tokens[$index]->isGivenKind(CT::T_ARRAY_SQUARE_BRACE_OPEN)) {
$index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_ARRAY_SQUARE_BRACE, $index);
}

if ($tokens[$index]->isGivenKind(T_ARRAY)) {
/** @var int $arrayOpenBraceIndex */
$arrayOpenBraceIndex = $tokens->getNextTokenOfKind($index, ['(']);

$index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $arrayOpenBraceIndex);
}

return $index + 1;
}
}
105 changes: 105 additions & 0 deletions src/Fixer/NoDuplicatedArrayKeyFixer.php
@@ -0,0 +1,105 @@
<?php

declare(strict_types = 1);

namespace PhpCsFixerCustomFixers\Fixer;

use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Tokens;
use PhpCsFixerCustomFixers\Analyzer\Analysis\ArrayElementAnalysis;
use PhpCsFixerCustomFixers\Analyzer\ArrayAnalyzer;
use PhpCsFixerCustomFixers\TokenRemover;

final class NoDuplicatedArrayKeyFixer extends AbstractFixer
{
public function getDefinition(): FixerDefinitionInterface
{
return new FixerDefinition(
'Duplicated array keys must be removed.',
[new CodeSample('<?php
$x = [
"foo" => 1,
"bar" => 2,
"foo" => 3,
];
')]
);
}

public function getPriority(): int
{
return 0;
}

public function isCandidate(Tokens $tokens): bool
{
return $tokens->isAnyTokenKindsFound([T_ARRAY, CT::T_ARRAY_SQUARE_BRACE_OPEN]);
}

public function isRisky(): bool
{
return false;
}

public function fix(\SplFileInfo $file, Tokens $tokens): void
{
for ($index = $tokens->count() - 1; $index > 0; $index--) {
if (!$tokens[$index]->isGivenKind([T_ARRAY, CT::T_ARRAY_SQUARE_BRACE_OPEN])) {
continue;
}

$this->fixArray($tokens, $index);
}
}

private function fixArray(Tokens $tokens, int $index): void
{
$arrayAnalyzer = new ArrayAnalyzer();

$keys = [];
foreach (\array_reverse($arrayAnalyzer->getElements($tokens, $index)) as $arrayElementAnalysis) {
$key = $this->getKeyContentIfPossible($tokens, $arrayElementAnalysis);
if ($key === null) {
continue;
}
if (isset($keys[$key])) {
/** @var int $startIndex */
$startIndex = $arrayElementAnalysis->getKeyStartIndex();

/** @var int $endIndex */
$endIndex = $tokens->getNextMeaningfulToken($arrayElementAnalysis->getValueEndIndex());
if ($tokens[$endIndex + 1]->isWhitespace() && Preg::match('/^\h+$/', $tokens[$endIndex + 1]->getContent()) === 1) {
$endIndex++;
}

$tokens->clearRange($startIndex + 1, $endIndex);
TokenRemover::removeWithLinesIfPossible($tokens, $startIndex);
}
$keys[$key] = true;
}
}

private function getKeyContentIfPossible(Tokens $tokens, ArrayElementAnalysis $arrayElementAnalysis): ?string
{
if ($arrayElementAnalysis->getKeyStartIndex() === null || $arrayElementAnalysis->getKeyEndIndex() === null) {
return null;
}

$content = '';
for ($index = $arrayElementAnalysis->getKeyEndIndex(); $index >= $arrayElementAnalysis->getKeyStartIndex(); $index--) {
if ($tokens[$index]->isWhitespace() || $tokens[$index]->isComment()) {
continue;
}
if ($tokens[$index]->equalsAny([[T_VARIABLE], '('])) {
return null;
}
$content .= $tokens[$index]->getContent();
}

return $content;
}
}
40 changes: 40 additions & 0 deletions tests/Analyzer/Analysis/ArrayElementAnalysisTest.php
@@ -0,0 +1,40 @@
<?php

declare(strict_types = 1);

namespace Tests\Analyzer\Analysis;

use PhpCsFixerCustomFixers\Analyzer\Analysis\ArrayElementAnalysis;
use PHPUnit\Framework\TestCase;

/**
* @internal
*
* @covers \PhpCsFixerCustomFixers\Analyzer\Analysis\ArrayElementAnalysis
*/
final class ArrayElementAnalysisTest extends TestCase
{
public function testGetKeyStartIndex(): void
{
$analysis = new ArrayElementAnalysis(1, 2, 3, 4);
self::assertSame(1, $analysis->getKeyStartIndex());
}

public function testGetKeyEndIndex(): void
{
$analysis = new ArrayElementAnalysis(1, 2, 3, 4);
self::assertSame(2, $analysis->getKeyEndIndex());
}

public function testGetValueStartIndex(): void
{
$analysis = new ArrayElementAnalysis(1, 2, 3, 4);
self::assertSame(3, $analysis->getValueStartIndex());
}

public function testGetValueEndIndex(): void
{
$analysis = new ArrayElementAnalysis(1, 2, 3, 4);
self::assertSame(4, $analysis->getValueEndIndex());
}
}

0 comments on commit b1cbe9b

Please sign in to comment.