Repository: PHP-CS-Fixer/PHP-CS-Fixer (php)

Issue:
Group imports only in functions
Given the example below, is it possible to group only the imports of functions?

```diff
<?php

use Illuminate\Filesystem\Filesystem;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;


-use function Laravel\Prompts\confirm;
-use function Laravel\Prompts\multiselect;
-use function Laravel\Prompts\select;
+use function Laravel\Prompts\{confirm, multiselect, select};

class InstallCommand extends Command
{
    // ...
}
```

Pull request description:
feat(GroupImportFixer): Ability to configure which type of imports should be grouped
Fixes #7220

Some existing test cases were changed because after changing the imports manipulation approach (each type separately) the final order of imports have changed (also because I've changed how `$insertIndex` is determined, as it's safer to insert group import where first removed import was, not the last - see the test case with `\DontTouch::me()`). I think it does not matter, as this fixer is not about the ordering, but about grouping.

Patch:
diff --git a/doc/rules/import/group_import.rst b/doc/rules/import/group_import.rst
index 35d755f28..c98a18bf4 100644
--- a/doc/rules/import/group_import.rst
+++ b/doc/rules/import/group_import.rst
@@ -4,12 +4,26 @@ Rule ``group_import``
 
 There MUST be group use for the same namespaces.
 
+Configuration
+-------------
+
+``group_types``
+~~~~~~~~~~~~~~~
+
+Defines the order of import types.
+
+Allowed types: ``list<string>``
+
+Default value: ``['classy', 'functions', 'constants']``
+
 Examples
 --------
 
 Example #1
 ~~~~~~~~~~
 
+*Default* configuration.
+
 .. code-block:: diff
 
    --- Original
@@ -18,6 +32,23 @@ Example #1
    -use Foo\Bar;
    -use Foo\Baz;
    +use Foo\{Bar, Baz};
+
+Example #2
+~~~~~~~~~~
+
+With configuration: ``['group_types' => ['classy']]``.
+
+.. code-block:: diff
+
+   --- Original
+   +++ New
+    <?php
+
+   -use A\Foo;
+    use function B\foo;
+   -use A\Bar;
+   +use A\{Bar, Foo};
+    use function B\bar;
 References
 ----------
 
diff --git a/phpstan.dist.neon b/phpstan.dist.neon
index 83752531e..cc9ae3f00 100644
--- a/phpstan.dist.neon
+++ b/phpstan.dist.neon
@@ -55,6 +55,6 @@ parameters:
         -
             message: '#^Method PhpCsFixer\\Tests\\.+::provide.+Cases\(\) return type has no value type specified in iterable type iterable\.$#'
             path: tests
-            count: 1014
+            count: 1013
     tipsOfTheDay: false
     tmpDir: dev-tools/phpstan/cache
diff --git a/src/Fixer/Import/GroupImportFixer.php b/src/Fixer/Import/GroupImportFixer.php
index 0bb9e85f0..1d47b2740 100644
--- a/src/Fixer/Import/GroupImportFixer.php
+++ b/src/Fixer/Import/GroupImportFixer.php
@@ -15,6 +15,10 @@
 namespace PhpCsFixer\Fixer\Import;
 
 use PhpCsFixer\AbstractFixer;
+use PhpCsFixer\Fixer\ConfigurableFixerInterface;
+use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
+use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
+use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
 use PhpCsFixer\FixerDefinition\CodeSample;
 use PhpCsFixer\FixerDefinition\FixerDefinition;
 use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
@@ -23,12 +27,24 @@
 use PhpCsFixer\Tokenizer\CT;
 use PhpCsFixer\Tokenizer\Token;
 use PhpCsFixer\Tokenizer\Tokens;
+use PhpCsFixer\Utils;
+use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException;
 
 /**
  * @author Volodymyr Kupriienko <vldmr.kuprienko@gmail.com>
+ * @author Greg Korba <greg@codito.dev>
  */
-final class GroupImportFixer extends AbstractFixer
+final class GroupImportFixer extends AbstractFixer implements ConfigurableFixerInterface
 {
+    /** @internal */
+    public const GROUP_CLASSY = 'classy';
+
+    /** @internal */
+    public const GROUP_CONSTANTS = 'constants';
+
+    /** @internal */
+    public const GROUP_FUNCTIONS = 'functions';
+
     public function getDefinition(): FixerDefinitionInterface
     {
         return new FixerDefinition(
@@ -37,6 +53,18 @@ public function getDefinition(): FixerDefinitionInterface
                 new CodeSample(
                     "<?php\nuse Foo\\Bar;\nuse Foo\\Baz;\n"
                 ),
+                new CodeSample(
+                    <<<'PHP'
+                        <?php
+
+                        use A\Foo;
+                        use function B\foo;
+                        use A\Bar;
+                        use function B\bar;
+
+                        PHP,
+                    ['group_types' => [self::GROUP_CLASSY]]
+                ),
             ]
         );
     }
@@ -46,24 +74,72 @@ public function isCandidate(Tokens $tokens): bool
         return $tokens->isTokenKindFound(T_USE);
     }
 
+    protected function createConfigurationDefinition(): FixerConfigurationResolverInterface
+    {
+        $allowedTypes = [self::GROUP_CLASSY, self::GROUP_FUNCTIONS, self::GROUP_CONSTANTS];
+
+        return new FixerConfigurationResolver([
+            (new FixerOptionBuilder('group_types', 'Defines the order of import types.'))
+                ->setAllowedTypes(['string[]'])
+                ->setAllowedValues([static function (array $types) use ($allowedTypes): bool {
+                    foreach ($types as $type) {
+                        if (!\in_array($type, $allowedTypes, true)) {
+                            throw new InvalidOptionsException(
+                                sprintf(
+                                    'Invalid group type: %s, allowed types: %s.',
+                                    $type,
+                                    Utils::naturalLanguageJoin($allowedTypes)
+                                )
+                            );
+                        }
+                    }
+
+                    return true;
+                }])
+                ->setDefault($allowedTypes)
+                ->getOption(),
+        ]);
+    }
+
     protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
     {
-        $useWithSameNamespaces = $this->getSameNamespaces($tokens);
+        $useWithSameNamespaces = $this->getSameNamespacesByType($tokens);
 
         if ([] === $useWithSameNamespaces) {
             return;
         }
 
-        $this->removeSingleUseStatements($useWithSameNamespaces, $tokens);
-        $this->addGroupUseStatements($useWithSameNamespaces, $tokens);
+        $typeMap = [
+            NamespaceUseAnalysis::TYPE_CLASS => self::GROUP_CLASSY,
+            NamespaceUseAnalysis::TYPE_FUNCTION => self::GROUP_FUNCTIONS,
+            NamespaceUseAnalysis::TYPE_CONSTANT => self::GROUP_CONSTANTS,
+        ];
+
+        // As a first step we need to remove all the use statements for the enabled import types.
+        // We can't add new group imports yet, because we need to operate on previously determined token indices for all types.
+        foreach ($useWithSameNamespaces as $type => $uses) {
+            if (!\in_array($typeMap[$type], $this->configuration['group_types'], true)) {
+                continue;
+            }
+
+            $this->removeSingleUseStatements($uses, $tokens);
+        }
+
+        foreach ($useWithSameNamespaces as $type => $uses) {
+            if (!\in_array($typeMap[$type], $this->configuration['group_types'], true)) {
+                continue;
+            }
+
+            $this->addGroupUseStatements($uses, $tokens);
+        }
     }
 
     /**
      * Gets namespace use analyzers with same namespaces.
      *
-     * @return list<NamespaceUseAnalysis>
+     * @return array<NamespaceUseAnalysis::TYPE_*, list<NamespaceUseAnalysis>>
      */
-    private function getSameNamespaces(Tokens $tokens): array
+    private function getSameNamespacesByType(Tokens $tokens): array
     {
         $useDeclarations = (new NamespaceUsesAnalyzer())->getDeclarationsFromTokens($tokens);
 
@@ -94,7 +170,14 @@ private function getSameNamespaces(Tokens $tokens): array
             return 0 !== $namespaceDifference ? $namespaceDifference : $a->getFullName() <=> $b->getFullName();
         });
 
-        return $sameNamespaceAnalysis;
+        $sameNamespaceAnalysisByType = [];
+        foreach ($sameNamespaceAnalysis as $analysis) {
+            $sameNamespaceAnalysisByType[$analysis->getType()][] = $analysis;
+        }
+
+        ksort($sameNamespaceAnalysisByType);
+
+        return $sameNamespaceAnalysisByType;
     }
 
     /**
@@ -134,7 +217,16 @@ private function removeSingleUseStatements(array $statements, Tokens $tokens): v
     private function addGroupUseStatements(array $statements, Tokens $tokens): void
     {
         $currentUseDeclaration = null;
-        $insertIndex = \array_slice($statements, -1)[0]->getEndIndex() + 1;
+        $insertIndex = $statements[0]->getStartIndex();
+
+        // If group import was inserted in place of removed imports, it may have more tokens than before,
+        // and indices stored in imports of another type can be out-of-sync, and can point in the middle of group import.
+        // Let's move the pointer to the closest empty token (erased single import).
+        if (null !== $tokens[$insertIndex]->getId() || '' !== $tokens[$insertIndex]->getContent()) {
+            do {
+                ++$insertIndex;
+            } while (null !== $tokens[$insertIndex]->getId() || '' !== $tokens[$insertIndex]->getContent());
+        }
 
         foreach ($statements as $index => $useDeclaration) {
             if ($this->areDeclarationsDifferent($currentUseDeclaration, $useDeclaration)) {
diff --git a/src/Tokenizer/Analyzer/Analysis/NamespaceUseAnalysis.php b/src/Tokenizer/Analyzer/Analysis/NamespaceUseAnalysis.php
index c1586fbd7..9056bf87d 100644
--- a/src/Tokenizer/Analyzer/Analysis/NamespaceUseAnalysis.php
+++ b/src/Tokenizer/Analyzer/Analysis/NamespaceUseAnalysis.php
@@ -68,6 +68,8 @@ final class NamespaceUseAnalysis implements StartEndTokenAwareAnalysis
 
     /**
      * The type of import: class, function or constant.
+     *
+     * @var self::TYPE_*
      */
     private int $type;
 
@@ -140,6 +142,9 @@ public function getChunkEndIndex(): ?int
         return $this->chunkEndIndex;
     }
 
+    /**
+     * @return self::TYPE_*
+     */
     public function getType(): int
     {
         return $this->type;


Test patch:
diff --git a/tests/Fixer/Import/GroupImportFixerTest.php b/tests/Fixer/Import/GroupImportFixerTest.php
index 97f65ff50..0ebfb4da0 100644
--- a/tests/Fixer/Import/GroupImportFixerTest.php
+++ b/tests/Fixer/Import/GroupImportFixerTest.php
@@ -14,25 +14,35 @@
 
 namespace PhpCsFixer\Tests\Fixer\Import;
 
+use PhpCsFixer\Fixer\Import\GroupImportFixer;
 use PhpCsFixer\Tests\Test\AbstractFixerTestCase;
 
 /**
  * @author Volodymyr Kupriienko <vldmr.kuprienko@gmail.com>
+ * @author Greg Korba <greg@codito.dev>
  *
  * @internal
  *
  * @covers \PhpCsFixer\Fixer\Import\GroupImportFixer
+ *
+ * @phpstan-type _Config array{group_types?: list<string>}
  */
 final class GroupImportFixerTest extends AbstractFixerTestCase
 {
     /**
      * @dataProvider provideFixCases
+     *
+     * @phpstan-param _Config $config
      */
-    public function testFix(string $expected, ?string $input = null): void
+    public function testFix(string $expected, ?string $input = null, ?array $config = []): void
     {
+        $this->fixer->configure($config);
         $this->doTest($expected, $input);
     }
 
+    /**
+     * @return iterable<array{0: string, 1?: string, 2?: _Config}>
+     */
     public static function provideFixCases(): iterable
     {
         yield [
@@ -88,8 +98,8 @@ public static function provideFixCases(): iterable
             '<?php
 
 use App\First;
-use Test\Second;
 use Foo\{Bar, Baz};
+use Test\Second;
 ',
             '<?php
 
@@ -197,10 +207,10 @@ public static function provideFixCases(): iterable
 
         yield [
             '<?php
-use Foo\Baz\John\Smith\Junior;
 use Foo\{Bar, Baz};
 use Foo\Baz\John\{Doe, Smith};
 use Foo\Baz\Johnny\{DoeSecond, SmithSecond};
+use Foo\Baz\John\Smith\Junior;
 ',
             '<?php
 use Foo\Bar;
@@ -279,12 +289,12 @@ public static function provideFixCases(): iterable
         yield [
             '<?php
 
+use Framework\Database\Query\JoinClause;
 use Framework\Support\{Arr, Collection};
 use Framework\Database\ORM\{Model, SoftDeletes};
 use Framework\Notifications\{Notifiable, Notification};
 use Framework\Support\Facades\{DB, Log};
 use Framework\Database\ORM\Relations\{BelongsTo, HasOne};
-use Framework\Database\Query\JoinClause;
 ',
             '<?php
 
@@ -305,9 +315,9 @@ public static function provideFixCases(): iterable
         yield [
             '<?php
 
-use Framework\Baz\Class6;
 use Framework\Bar\{Class3, Class4, Class5};
 use Framework\Foo\{Class1, Class2, Class7};
+use Framework\Baz\Class6;
 ',
             '<?php
 
@@ -324,8 +334,8 @@ public static function provideFixCases(): iterable
         yield [
             '<?php
 
-use function Foo\baz;
 use Foo\{Bar, Baz};
+use function Foo\baz;
 ',
             '<?php
 
@@ -338,21 +348,177 @@ public static function provideFixCases(): iterable
         yield [
             '<?php
 
+use Foo\Bar\C;
 use Foo\{D, E};
+use Foo\Bar\Baz\{A, B};
 use function Foo\{a, b};
+',
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
+use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use function Foo\a;
+use function Foo\b;
+',
+        ];
+
+        yield 'only class-like' => [
+            '<?php
+
+use Foo\Bar\C;
+use Foo\{D, E};
 use Foo\Bar\Baz\{A, B};
+use function Foo\a;
+use function Foo\b;
+use const Foo\X;
+use const Foo\Y;
+',
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
+use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use function Foo\a;
+use function Foo\b;
+use const Foo\X;
+use const Foo\Y;
+',
+            ['group_types' => [GroupImportFixer::GROUP_CLASSY]],
+        ];
+
+        yield 'only functions' => [
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
+use function Foo\{a, b};
 use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use const Foo\X;
+use const Foo\Y;
 ',
             '<?php
 
 use Foo\Bar\Baz\A;
 use Foo\Bar\Baz\B;
+use function Foo\a;
+use function Foo\b;
 use Foo\Bar\C;
 use Foo\D;
 use Foo\E;
+use const Foo\X;
+use const Foo\Y;
+',
+            ['group_types' => [GroupImportFixer::GROUP_FUNCTIONS]],
+        ];
+
+        yield 'only constants' => [
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
 use function Foo\a;
 use function Foo\b;
+use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use const Foo\{X, Y};
+',
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
+use function Foo\a;
+use function Foo\b;
+use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use const Foo\X;
+use const Foo\Y;
+',
+            ['group_types' => [GroupImportFixer::GROUP_CONSTANTS]],
+        ];
+
+        yield 'class-likes and functions' => [
+            '<?php
+
+use function Foo\{a, b};
+use Foo\Bar\C;
+use Foo\{D, E};
+use Foo\Bar\Baz\{A, B};
+use const Foo\X;
+use const Foo\Y;
+',
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
+use function Foo\a;
+use function Foo\b;
+use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use const Foo\X;
+use const Foo\Y;
+',
+            ['group_types' => [GroupImportFixer::GROUP_CLASSY, GroupImportFixer::GROUP_FUNCTIONS]],
+        ];
+
+        yield 'class-likes and constants' => [
+            '<?php
+
+use function Foo\a;
+use function Foo\b;
+use Foo\Bar\C;
+use Foo\{D, E};
+use Foo\Bar\Baz\{A, B};
+use const Foo\{X, Y};
+',
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
+use function Foo\a;
+use function Foo\b;
+use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use const Foo\X;
+use const Foo\Y;
+',
+            ['group_types' => [GroupImportFixer::GROUP_CLASSY, GroupImportFixer::GROUP_CONSTANTS]],
+        ];
+
+        yield 'functions and constants' => [
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
+use function Foo\{a, b};
+use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use const Foo\{X, Y};
+',
+            '<?php
+
+use Foo\Bar\Baz\A;
+use Foo\Bar\Baz\B;
+use function Foo\a;
+use function Foo\b;
+use Foo\Bar\C;
+use Foo\D;
+use Foo\E;
+use const Foo\X;
+use const Foo\Y;
 ',
+            ['group_types' => [GroupImportFixer::GROUP_FUNCTIONS, GroupImportFixer::GROUP_CONSTANTS]],
         ];
     }
 
@@ -371,10 +537,10 @@ public static function provideFixPre80Cases(): iterable
         yield [
             '<?php
 
-/*1*//*2*//*3*//*4*//*5*//*6*/
+/*1*/use A\{B, C, D};
+/*2*//*3*//*4*//*5*//*6*/
 /*7*//*8*//*9*//*10*//*11*//*12*/
-/*13*//*14*//*15*//*16*//*17*/use A\{B, C, D};
-/*18*/
+/*13*//*14*//*15*//*16*//*17*//*18*/
 ',
             '<?php