Browse Source

OrderedInterfacesFixer - Introduction

Dave van der Brugge 6 years ago
parent
commit
cbc655ae94

+ 13 - 0
README.rst

@@ -1241,6 +1241,19 @@ Choose from the list of available rules:
     should be sorted alphabetically or by length, or not sorted; defaults
     to ``'alpha'``; DEPRECATED alias: ``sortAlgorithm``
 
+* **ordered_interfaces**
+
+  Orders the interfaces in an ``implements`` or ``interface extends`` clause.
+
+  *Risky rule: risky for ``implements`` when specifying both an interface and its parent interface, because PHP doesn't break on ``parent, child`` but does on ``child, parent``.*
+
+  Configuration options:
+
+  - ``direction`` (``'ascend'``, ``'descend'``): which direction the interfaces should
+    be ordered; defaults to ``'ascend'``
+  - ``order`` (``'alpha'``, ``'length'``): how the interfaces should be ordered;
+    defaults to ``'alpha'``
+
 * **php_unit_construct** [@Symfony:risky, @PhpCsFixer:risky]
 
   PHPUnit assertion method calls like ``->assertSame(true, $foo)`` should be

+ 231 - 0
src/Fixer/ClassNotation/OrderedInterfacesFixer.php

@@ -0,0 +1,231 @@
+<?php
+
+/*
+ * This file is part of PHP CS Fixer.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace PhpCsFixer\Fixer\ClassNotation;
+
+use PhpCsFixer\AbstractFixer;
+use PhpCsFixer\Fixer\ConfigurationDefinitionFixerInterface;
+use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
+use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
+use PhpCsFixer\FixerDefinition\CodeSample;
+use PhpCsFixer\FixerDefinition\FixerDefinition;
+use PhpCsFixer\Tokenizer\Token;
+use PhpCsFixer\Tokenizer\Tokens;
+
+/**
+ * @author Dave van der Brugge <dmvdbrugge@gmail.com>
+ */
+final class OrderedInterfacesFixer extends AbstractFixer implements ConfigurationDefinitionFixerInterface
+{
+    /** @internal */
+    const OPTION_DIRECTION = 'direction';
+
+    /** @internal */
+    const OPTION_ORDER = 'order';
+
+    /** @internal */
+    const DIRECTION_ASCEND = 'ascend';
+
+    /** @internal */
+    const DIRECTION_DESCEND = 'descend';
+
+    /** @internal */
+    const ORDER_ALPHA = 'alpha';
+
+    /** @internal */
+    const ORDER_LENGTH = 'length';
+
+    /**
+     * Array of supported directions in configuration.
+     *
+     * @var string[]
+     */
+    private $supportedDirectionOptions = [
+        self::DIRECTION_ASCEND,
+        self::DIRECTION_DESCEND,
+    ];
+
+    /**
+     * Array of supported orders in configuration.
+     *
+     * @var string[]
+     */
+    private $supportedOrderOptions = [
+        self::ORDER_ALPHA,
+        self::ORDER_LENGTH,
+    ];
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getDefinition()
+    {
+        return new FixerDefinition(
+            'Orders the interfaces in an `implements` or `interface extends` clause.',
+            [
+                new CodeSample(
+                    "<?php\n\nfinal class ExampleA implements Gamma, Alpha, Beta {}\n\ninterface ExampleB extends Gamma, Alpha, Beta {}\n"
+                ),
+                new CodeSample(
+                    "<?php\n\nfinal class ExampleA implements Gamma, Alpha, Beta {}\n\ninterface ExampleB extends Gamma, Alpha, Beta {}\n",
+                    [self::OPTION_DIRECTION => self::DIRECTION_DESCEND]
+                ),
+                new CodeSample(
+                    "<?php\n\nfinal class ExampleA implements MuchLonger, Short, Longer {}\n\ninterface ExampleB extends MuchLonger, Short, Longer {}\n",
+                    [self::OPTION_ORDER => self::ORDER_LENGTH]
+                ),
+                new CodeSample(
+                    "<?php\n\nfinal class ExampleA implements MuchLonger, Short, Longer {}\n\ninterface ExampleB extends MuchLonger, Short, Longer {}\n",
+                    [
+                        self::OPTION_ORDER => self::ORDER_LENGTH,
+                        self::OPTION_DIRECTION => self::DIRECTION_DESCEND,
+                    ]
+                ),
+            ],
+            null,
+            "Risky for `implements` when specifying both an interface and its parent interface, because PHP doesn't break on `parent, child` but does on `child, parent`."
+        );
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function isCandidate(Tokens $tokens)
+    {
+        return $tokens->isTokenKindFound(T_IMPLEMENTS)
+            || $tokens->isAllTokenKindsFound([T_INTERFACE, T_EXTENDS]);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function isRisky()
+    {
+        return true;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function applyFix(\SplFileInfo $file, Tokens $tokens)
+    {
+        foreach ($tokens as $index => $token) {
+            if (!$token->isGivenKind(T_IMPLEMENTS)) {
+                if (!$token->isGivenKind(T_EXTENDS)) {
+                    continue;
+                }
+
+                $nameTokenIndex = $tokens->getPrevMeaningfulToken($index);
+                $interfaceTokenIndex = $tokens->getPrevMeaningfulToken($nameTokenIndex);
+                $interfaceToken = $tokens[$interfaceTokenIndex];
+
+                if (!$interfaceToken->isGivenKind(T_INTERFACE)) {
+                    continue;
+                }
+            }
+
+            $interfaceIndex = 0;
+            $interfaces = [['tokens' => []]];
+
+            $implementsStart = $index + 1;
+            $classStart = $tokens->getNextTokenOfKind($implementsStart, ['{']);
+            $implementsEnd = $tokens->getPrevNonWhitespace($classStart);
+
+            for ($i = $implementsStart; $i <= $implementsEnd; ++$i) {
+                if ($tokens[$i]->equals(',')) {
+                    ++$interfaceIndex;
+                    $interfaces[$interfaceIndex] = ['tokens' => []];
+
+                    continue;
+                }
+
+                $interfaces[$interfaceIndex]['tokens'][] = $tokens[$i];
+            }
+
+            if (1 === \count($interfaces)) {
+                continue;
+            }
+
+            foreach ($interfaces as $interfaceIndex => $interface) {
+                $interfaceTokens = Tokens::fromArray($interface['tokens'], false);
+
+                $normalized = '';
+                $actualInterfaceIndex = $interfaceTokens->getNextMeaningfulToken(-1);
+
+                while ($interfaceTokens->offsetExists($actualInterfaceIndex)) {
+                    $token = $interfaceTokens[$actualInterfaceIndex];
+
+                    if (null === $token || $token->isComment() || $token->isWhitespace()) {
+                        break;
+                    }
+
+                    $normalized .= str_replace('\\', ' ', $token->getContent());
+                    ++$actualInterfaceIndex;
+                }
+
+                $interfaces[$interfaceIndex]['normalized'] = $normalized;
+                $interfaces[$interfaceIndex]['originalIndex'] = $interfaceIndex;
+            }
+
+            usort($interfaces, function (array $first, array $second) {
+                $score = self::ORDER_LENGTH === $this->configuration[self::OPTION_ORDER]
+                    ? \strlen($first['normalized']) - \strlen($second['normalized'])
+                    : strcasecmp($first['normalized'], $second['normalized']);
+
+                if (self::DIRECTION_DESCEND === $this->configuration[self::OPTION_DIRECTION]) {
+                    $score *= -1;
+                }
+
+                return $score;
+            });
+
+            $changed = false;
+
+            foreach ($interfaces as $interfaceIndex => $interface) {
+                if ($interface['originalIndex'] !== $interfaceIndex) {
+                    $changed = true;
+
+                    break;
+                }
+            }
+
+            if (!$changed) {
+                continue;
+            }
+
+            $newTokens = array_shift($interfaces)['tokens'];
+
+            foreach ($interfaces as $interface) {
+                array_push($newTokens, new Token(','), ...$interface['tokens']);
+            }
+
+            $tokens->overrideRange($implementsStart, $implementsEnd, $newTokens);
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    protected function createConfigurationDefinition()
+    {
+        return new FixerConfigurationResolver([
+            (new FixerOptionBuilder(self::OPTION_ORDER, 'How the interfaces should be ordered'))
+                ->setAllowedValues($this->supportedOrderOptions)
+                ->setDefault(self::ORDER_ALPHA)
+                ->getOption(),
+            (new FixerOptionBuilder(self::OPTION_DIRECTION, 'Which direction the interfaces should be ordered'))
+                ->setAllowedValues($this->supportedDirectionOptions)
+                ->setDefault(self::DIRECTION_ASCEND)
+                ->getOption(),
+        ]);
+    }
+}

+ 257 - 0
tests/Fixer/ClassNotation/OrderedInterfacesFixerTest.php

@@ -0,0 +1,257 @@
+<?php
+
+/*
+ * This file is part of PHP CS Fixer.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *     Dariusz Rumiński <dariusz.ruminski@gmail.com>
+ *
+ * This source file is subject to the MIT license that is bundled
+ * with this source code in the file LICENSE.
+ */
+
+namespace PhpCsFixer\Tests\Fixer\ClassNotation;
+
+use PhpCsFixer\Tests\Test\AbstractFixerTestCase;
+
+/**
+ * @author Dave van der Brugge <dmvdbrugge@gmail.com>
+ *
+ * @internal
+ *
+ * @covers \PhpCsFixer\Fixer\ClassNotation\OrderedInterfacesFixer
+ */
+final class OrderedInterfacesFixerTest extends AbstractFixerTestCase
+{
+    /**
+     * @dataProvider provideFixAlphaCases
+     *
+     * @param string      $expected
+     * @param null|string $input
+     */
+    public function testFixAlpha($expected, $input = null)
+    {
+        $this->doTest($expected, $input);
+    }
+
+    public function provideFixAlphaCases()
+    {
+        return [
+            'single' => [
+                '<?php class T implements A {}',
+            ],
+            'multiple' => [
+                '<?php class T implements A, B, C {}',
+                '<?php class T implements C, A, B {}',
+            ],
+            'newlines' => [
+                "<?php class T implements\nB,\nC\n{}",
+                "<?php class T implements\nC,\nB\n{}",
+            ],
+            'newlines and comments' => [
+                "<?php class T implements\n// Here's A\nA,\n// Here's B\nB\n{}",
+                "<?php class T implements\n// Here's B\nB,\n// Here's A\nA\n{}",
+            ],
+            'no whitespace' => [
+                '<?php class T implements/*An*/AnInterface/*end*/,/*Second*/SecondInterface{}',
+                '<?php class T implements/*Second*/SecondInterface,/*An*/AnInterface/*end*/{}',
+            ],
+            'FQCN' => [
+                '<?php class T implements \F\Q\C\N, \F\Q\I\N {}',
+                '<?php class T implements \F\Q\I\N, \F\Q\C\N {}',
+            ],
+            'mixed' => [
+                '<?php class T implements \F\Q\C\N, Partially\Q\C\N, /* Who mixes these? */ UnNamespaced {}',
+                '<?php class T implements /* Who mixes these? */ UnNamespaced, \F\Q\C\N, Partially\Q\C\N {}',
+            ],
+            'multiple in file' => [
+                '<?php
+                    class A1 implements A\B\C, Z\X\Y {}
+                    class B2 implements A\B, Z\X {}
+                    class C3 implements A, Z\X {}
+                    class D4 implements A\B, B\V, Z\X\V {}
+                    class E5 implements U\B, X\B, Y\V, Z\X\V {}
+                ',
+                '<?php
+                    class A1 implements Z\X\Y, A\B\C {}
+                    class B2 implements Z\X, A\B {}
+                    class C3 implements Z\X, A {}
+                    class D4 implements Z\X\V, B\V, A\B {}
+                    class E5 implements Z\X\V, Y\V, X\B, U\B {}
+                ',
+            ],
+            'interface extends' => [
+                '<?php interface T extends A, B, C {}',
+                '<?php interface T extends C, A, B {}',
+            ],
+        ];
+    }
+
+    /**
+     * @requires PHP 7.0
+     * @dataProvider provideFixAlpha70Cases
+     *
+     * @param string      $expected
+     * @param null|string $input
+     */
+    public function testFixAlpha70($expected, $input = null)
+    {
+        $this->doTest($expected, $input);
+    }
+
+    public function provideFixAlpha70Cases()
+    {
+        return [
+            'nested anonymous classes' => [
+                '<?php
+                    class T implements A, B, C
+                    {
+                        public function getAnonymousClassObject()
+                        {
+                            return new class() implements C, D, E
+                            {
+                                public function getNestedAnonymousClassObject()
+                                {
+                                    return new class() implements E, F, G {};
+                                }
+                            };
+                        }
+                    }
+                ',
+                '<?php
+                    class T implements C, A, B
+                    {
+                        public function getAnonymousClassObject()
+                        {
+                            return new class() implements E, C, D
+                            {
+                                public function getNestedAnonymousClassObject()
+                                {
+                                    return new class() implements F, G, E {};
+                                }
+                            };
+                        }
+                    }
+                ',
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideFixAlphaDescendCases
+     *
+     * @param string      $expected
+     * @param null|string $input
+     */
+    public function testFixAlphaDescend($expected, $input = null)
+    {
+        $this->fixer->configure(['direction' => 'descend']);
+        $this->doTest($expected, $input);
+    }
+
+    public function provideFixAlphaDescendCases()
+    {
+        return [
+            'single' => [
+                '<?php class T implements A {}',
+            ],
+            'multiple' => [
+                '<?php class T implements C, B, A {}',
+                '<?php class T implements C, A, B {}',
+            ],
+            'mixed' => [
+                '<?php class T implements /* Who mixes these? */ UnNamespaced, Partially\Q\C\N, \F\Q\C\N {}',
+                '<?php class T implements /* Who mixes these? */ UnNamespaced, \F\Q\C\N, Partially\Q\C\N {}',
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideFixLengthCases
+     *
+     * @param string      $expected
+     * @param null|string $input
+     */
+    public function testFixLength($expected, $input = null)
+    {
+        $this->fixer->configure(['order' => 'length']);
+        $this->doTest($expected, $input);
+    }
+
+    public function provideFixLengthCases()
+    {
+        return [
+            'single' => [
+                '<?php class A implements A {}',
+            ],
+            'multiple' => [
+                '<?php class A implements Short, Longer, MuchLonger {}',
+                '<?php class A implements MuchLonger, Short, Longer {}',
+            ],
+            'mixed' => [
+                '<?php class T implements \F\Q\C\N, /* Who mixes these? */ UnNamespaced, Partially\Q\C\N {}',
+                '<?php class T implements /* Who mixes these? */ UnNamespaced, \F\Q\C\N, Partially\Q\C\N {}',
+            ],
+            'normalized' => [
+                '<?php
+                    class A implements
+                         ABCDE,
+                         A\B\C\D
+                    { /* */ }
+                ',
+                '<?php
+                    class A implements
+                         A\B\C\D,
+                         ABCDE
+                    { /* */ }
+                ',
+            ],
+        ];
+    }
+
+    /**
+     * @dataProvider provideFixLengthDescendCases
+     *
+     * @param string      $expected
+     * @param null|string $input
+     */
+    public function testFixLengthDescend($expected, $input = null)
+    {
+        $this->fixer->configure([
+            'order' => 'length',
+            'direction' => 'descend',
+        ]);
+        $this->doTest($expected, $input);
+    }
+
+    public function provideFixLengthDescendCases()
+    {
+        return [
+            'single' => [
+                '<?php class A implements A {}',
+            ],
+            'multiple' => [
+                '<?php class A implements MuchLonger, Longer, Short {}',
+                '<?php class A implements MuchLonger, Short, Longer {}',
+            ],
+            'mixed' => [
+                '<?php class T implements Partially\Q\C\N, /* Who mixes these? */ UnNamespaced, \F\Q\C\N {}',
+                '<?php class T implements /* Who mixes these? */ UnNamespaced, \F\Q\C\N, Partially\Q\C\N {}',
+            ],
+            'normalized' => [
+                '<?php
+                    class A implements
+                         A\B\C\D,
+                         ABCDE
+                    { /* */ }
+                ',
+                '<?php
+                    class A implements
+                         ABCDE,
+                         A\B\C\D
+                    { /* */ }
+                ',
+            ],
+        ];
+    }
+}