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

Issue:
`leading_backslash_in_global_namespace` breaks generics
## Bug report

Currently `fully_qualified_strict_types` with `leading_backslash_in_global_namespace` option fixes local generics symbols, example:

```diff
 <?php
 
 class MethodGenerics
 {
     /**
      * @template T of \Exception
      * @param \Closure(): T $fx
-     * @return T
+     * @return \T
      */
     public function makeException(\Closure $fx)
     {
         return $fx();
     }
 }
 
 /**
  * @template T of \Exception
  */
 class ClassGenerics
 {
     /**
      * @param \Closure(): T $fx
-     * @return T
+     * @return \T
      */
     public function makeException(\Closure $fx)
     {
         return $fx();
     }
 }
```

This is wrong, there is nothing to be fixed.

I expect the same also for local in-class type aliases [2].

[1] https://phpstan.org/blog/generics-in-php-using-phpdocs#type-variables
[2] https://phpstan.org/writing-php-code/phpdoc-types#local-type-aliases

Pull request description:
fix: `fully_qualified_strict_types` must honor template/local type identifiers
fix #7720

Patch:
diff --git a/src/Fixer/Import/FullyQualifiedStrictTypesFixer.php b/src/Fixer/Import/FullyQualifiedStrictTypesFixer.php
index b416a7928..ca5c14c0c 100644
--- a/src/Fixer/Import/FullyQualifiedStrictTypesFixer.php
+++ b/src/Fixer/Import/FullyQualifiedStrictTypesFixer.php
@@ -63,6 +63,11 @@ final class FullyQualifiedStrictTypesFixer extends AbstractFixer implements Conf
      */
     private array $symbolsForImport = [];
 
+    /**
+     * @var array<int<0, max>, array<string, true>>
+     */
+    private array $reservedIdentifiersByLevel;
+
     /** @var array<string, string> */
     private array $cacheUsesLast = [];
 
@@ -278,10 +283,18 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
                     $classyKinds[] = T_ENUM;
                 }
 
+                $openedCurlyBrackets = 0;
+                $this->reservedIdentifiersByLevel = [];
+
                 for ($index = $namespace->getScopeStartIndex(); $index < $namespace->getScopeEndIndex() + $indexDiff; ++$index) {
                     $origSize = \count($tokens);
 
-                    if ($discoverSymbolsPhase && $tokens[$index]->isGivenKind($classyKinds)) {
+                    if ($tokens[$index]->equals('{')) {
+                        ++$openedCurlyBrackets;
+                    } if ($tokens[$index]->equals('}')) {
+                        unset($this->reservedIdentifiersByLevel[$openedCurlyBrackets]);
+                        --$openedCurlyBrackets;
+                    } elseif ($discoverSymbolsPhase && $tokens[$index]->isGivenKind($classyKinds)) {
                         $this->fixNextName($tokens, $index, $uses, $namespaceName);
                     } elseif ($tokens[$index]->isGivenKind(T_FUNCTION)) {
                         $this->fixFunction($functionsAnalyzer, $tokens, $index, $uses, $namespaceName);
@@ -303,12 +316,19 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
                     } elseif ($discoverSymbolsPhase && !\defined('T_ATTRIBUTE') && $tokens[$index]->isComment() && Preg::match('/#\[\s*('.self::REGEX_CLASS.')/', $tokens[$index]->getContent(), $matches)) { // @TODO: drop when PHP 8.0+ is required
                         $this->determineShortType($matches[1], $uses, $namespaceName);
                     } elseif ($tokens[$index]->isGivenKind(T_DOC_COMMENT)) {
+                        Preg::matchAll('/\*\h*@(?:psalm-|phpstan-)?(?:template(?:-covariant|-contravariant)?|(?:import-)?type)\h+('.TypeExpression::REGEX_IDENTIFIER.')(?!\S)/i', $tokens[$index]->getContent(), $matches);
+                        foreach ($matches[1] as $reservedIdentifier) {
+                            $this->reservedIdentifiersByLevel[$openedCurlyBrackets + 1][$reservedIdentifier] = true;
+                        }
+
                         $this->fixPhpDoc($tokens, $index, $uses, $namespaceName);
                     }
 
                     $indexDiff += \count($tokens) - $origSize;
                 }
 
+                $this->reservedIdentifiersByLevel = [];
+
                 if ($discoverSymbolsPhase) {
                     $this->setupUsesFromDiscoveredSymbols($uses, $namespaceName);
                 }
@@ -354,6 +374,25 @@ private function refreshUsesCache(array $uses): void
         }
     }
 
+    private function isReservedIdentifier(string $symbol): bool
+    {
+        if (str_contains($symbol, '\\')) { // optimization only
+            return false;
+        }
+
+        if ((new TypeAnalysis($symbol))->isReservedType()) {
+            return true;
+        }
+
+        foreach ($this->reservedIdentifiersByLevel as $reservedIdentifiers) {
+            if (isset($reservedIdentifiers[$symbol])) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
     /**
      * Resolve absolute or relative symbol to normalized FQCN.
      *
@@ -365,6 +404,10 @@ private function resolveSymbol(string $symbol, array $uses, string $namespaceNam
             return substr($symbol, 1);
         }
 
+        if ($this->isReservedIdentifier($symbol)) {
+            return $symbol;
+        }
+
         $this->refreshUsesCache($uses);
 
         $symbolArr = explode('\\', $symbol, 2);
@@ -383,6 +426,10 @@ private function resolveSymbol(string $symbol, array $uses, string $namespaceNam
      */
     private function shortenSymbol(string $fqcn, array $uses, string $namespaceName): string
     {
+        if ($this->isReservedIdentifier($fqcn)) {
+            return $fqcn;
+        }
+
         $this->refreshUsesCache($uses);
 
         $res = null;
@@ -391,7 +438,7 @@ private function shortenSymbol(string $fqcn, array $uses, string $namespaceName)
         $iMin = 0;
         if (str_starts_with($fqcn, $namespaceName.'\\')) {
             $tmpRes = substr($fqcn, \strlen($namespaceName) + 1);
-            if (!isset($this->cacheUseNameByShortNameLower[strtolower(explode('\\', $tmpRes, 2)[0])])) {
+            if (!isset($this->cacheUseNameByShortNameLower[strtolower(explode('\\', $tmpRes, 2)[0])]) && !$this->isReservedIdentifier($tmpRes)) {
                 $res = $tmpRes;
                 $iMin = substr_count($namespaceName, '\\') + 1;
             }
@@ -401,9 +448,12 @@ private function shortenSymbol(string $fqcn, array $uses, string $namespaceName)
         $tmp = $fqcn;
         for ($i = substr_count($fqcn, '\\'); $i >= $iMin; --$i) {
             if (isset($this->cacheUseShortNameByNameLower[strtolower($tmp)])) {
-                $res = $this->cacheUseShortNameByNameLower[strtolower($tmp)].substr($fqcn, \strlen($tmp));
+                $tmpRes = $this->cacheUseShortNameByNameLower[strtolower($tmp)].substr($fqcn, \strlen($tmp));
+                if (!$this->isReservedIdentifier($tmpRes)) {
+                    $res = $tmpRes;
 
-                break;
+                    break;
+                }
             }
 
             if ($i > 0) {
@@ -741,12 +791,10 @@ private function replaceByShortType(Tokens $tokens, TypeAnalysis $type, array $u
      */
     private function determineShortType(string $typeName, array $uses, string $namespaceName): ?array
     {
-        if ((new TypeAnalysis($typeName))->isReservedType()) {
-            return null;
-        }
-
         if (null !== $this->discoveredSymbols) {
-            $this->discoveredSymbols['class'][] = $typeName;
+            if (!$this->isReservedIdentifier($typeName)) {
+                $this->discoveredSymbols['class'][] = $typeName;
+            }
 
             return null;
         }


Test patch:
diff --git a/tests/Fixer/Import/FullyQualifiedStrictTypesFixerTest.php b/tests/Fixer/Import/FullyQualifiedStrictTypesFixerTest.php
index 2c9011f3e..879574f0e 100644
--- a/tests/Fixer/Import/FullyQualifiedStrictTypesFixerTest.php
+++ b/tests/Fixer/Import/FullyQualifiedStrictTypesFixerTest.php
@@ -798,6 +798,318 @@ class Cl {}
             ['import_symbols' => true],
         ];
 
+        yield 'prevent import if implicitly used by generics template' => [
+            <<<'EOD'
+                <?php
+
+                namespace Ns;
+                use Foo\T;
+
+                class Cl
+                {
+                    /**
+                     * @return T
+                     */
+                    public function before()
+                    {
+                        return new T();
+                    }
+
+                    /**
+                     * @template T of \Exception
+                     * @param \Closure(\Foo\T): T $fx
+                     * @return T
+                     */
+                    public function makeException(\Closure $fx)
+                    {
+                        $arg = new \Foo\T();
+
+                        return $fx($arg);
+                    }
+
+                    /**
+                     * @return T
+                     */
+                    public function after()
+                    {
+                        return new T();
+                    }
+
+                    /**
+                     * @return T
+                     */
+                    public function anony()
+                    {
+                        $anony = new
+                        /**
+                         * @template T of \Exception
+                         */
+                        class(new RuntimeException()) {
+                            /** @var T */
+                            public \Exception $e;
+
+                            /**
+                             * @param T $e
+                             */
+                            public function __construct(\Exception $e)
+                            {
+                                $this->e = $e;
+                            }
+
+                            public function before(): void
+                            {
+                                new \Foo\T();
+                            }
+
+                            /**
+                             * @return T
+                             */
+                            public function returnT()
+                            {
+                                new \Foo\T();
+
+                                return $this->e;
+                            }
+
+                            public function after(): void
+                            {
+                                new \Foo\T();
+                            }
+                        };
+
+                        return new T();
+                    }
+                }
+                EOD,
+            <<<'EOD'
+                <?php
+
+                namespace Ns;
+
+                class Cl
+                {
+                    /**
+                     * @return \Foo\T
+                     */
+                    public function before()
+                    {
+                        return new \Foo\T();
+                    }
+
+                    /**
+                     * @template T of \Exception
+                     * @param \Closure(\Foo\T): T $fx
+                     * @return T
+                     */
+                    public function makeException(\Closure $fx)
+                    {
+                        $arg = new \Foo\T();
+
+                        return $fx($arg);
+                    }
+
+                    /**
+                     * @return \Foo\T
+                     */
+                    public function after()
+                    {
+                        return new \Foo\T();
+                    }
+
+                    /**
+                     * @return \Foo\T
+                     */
+                    public function anony()
+                    {
+                        $anony = new
+                        /**
+                         * @template T of \Exception
+                         */
+                        class(new RuntimeException()) {
+                            /** @var T */
+                            public \Exception $e;
+
+                            /**
+                             * @param T $e
+                             */
+                            public function __construct(\Exception $e)
+                            {
+                                $this->e = $e;
+                            }
+
+                            public function before(): void
+                            {
+                                new \Foo\T();
+                            }
+
+                            /**
+                             * @return T
+                             */
+                            public function returnT()
+                            {
+                                new \Foo\T();
+
+                                return $this->e;
+                            }
+
+                            public function after(): void
+                            {
+                                new \Foo\T();
+                            }
+                        };
+
+                        return new \Foo\T();
+                    }
+                }
+                EOD,
+            ['import_symbols' => true],
+        ];
+
+        yield 'prevent import if implicitly used by local type' => [
+            <<<'EOD'
+                <?php
+
+                namespace Ns;
+                use Ns2\Bar;
+                use Ns2\Foo;
+
+                /**
+                 * @phpstan-type Foo array{int, int}
+                 * @phpstan-import-type Bar from OtherCl
+                 */
+                class Cl
+                {
+                    /**
+                     * @param \Ns2\Foo $v
+                     *
+                     * @return Foo
+                     */
+                    public function foo($v)
+                    {
+                        return [1, 2];
+                    }
+
+                    /**
+                     * @param \Ns2\Bar $v
+                     *
+                     * @return Bar
+                     */
+                    public function bar($v)
+                    {
+                        return null;
+                    }
+                }
+
+                class Cl2
+                {
+                    /**
+                     * @param Foo $v
+                     */
+                    public function foo($v): void {}
+
+                    /**
+                     * @param Bar $v
+                     */
+                    public function bar($v): void {}
+                }
+                EOD,
+            <<<'EOD'
+                <?php
+
+                namespace Ns;
+
+                /**
+                 * @phpstan-type Foo array{int, int}
+                 * @phpstan-import-type Bar from OtherCl
+                 */
+                class Cl
+                {
+                    /**
+                     * @param \Ns2\Foo $v
+                     *
+                     * @return Foo
+                     */
+                    public function foo($v)
+                    {
+                        return [1, 2];
+                    }
+
+                    /**
+                     * @param \Ns2\Bar $v
+                     *
+                     * @return Bar
+                     */
+                    public function bar($v)
+                    {
+                        return null;
+                    }
+                }
+
+                class Cl2
+                {
+                    /**
+                     * @param \Ns2\Foo $v
+                     */
+                    public function foo($v): void {}
+
+                    /**
+                     * @param \Ns2\Bar $v
+                     */
+                    public function bar($v): void {}
+                }
+                EOD,
+            ['import_symbols' => true],
+        ];
+
+        yield 'prevent import if implicitly used by generics template - psalm/phpstan prefix' => [
+            <<<'EOD'
+                <?php
+
+                /** @psalm-template T1 */
+                /** @phpstan-template T2 */
+                class Foo {
+                    /** @var T1 */
+                    public $v1;
+                    /** @var T2 */
+                    public $v2;
+                    /** @var \T3 */
+                    public $v3;
+                }
+                EOD,
+            <<<'EOD'
+                <?php
+
+                /** @psalm-template T1 */
+                /** @phpstan-template T2 */
+                class Foo {
+                    /** @var T1 */
+                    public $v1;
+                    /** @var T2 */
+                    public $v2;
+                    /** @var T3 */
+                    public $v3;
+                }
+                EOD,
+            ['leading_backslash_in_global_namespace' => true],
+        ];
+
+        yield 'prevent import if implicitly used by generics template - covariant/contravariant suffix' => [
+            <<<'EOD'
+                <?php
+
+                /** @template-covariant T1 */
+                /** @psalm-template-contravariant T2 */
+                class Foo {
+                    /** @var T1 */
+                    public $v1;
+                    /** @var T2 */
+                    public $v2;
+                }
+                EOD,
+            null,
+            ['leading_backslash_in_global_namespace' => true],
+        ];
+
         yield 'import with relative and absolute symbols - global' => [
             <<<'EOD'
                 <?php