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

Issue:
phpdoc_to_comment fixer is confused by attributes
## Bug report

If I attach a PHPDoc block and an attribute to my class, the `phpdoc_to_comment` fixer wants to fix the PHPDoc block to a regular comment. I think it should not do that.

I have verified this issue with the latest 2.17-dev version.

### Code snippet that reproduces the problem

```php
namespace App\Controller;

use Symfony\Component\Routing\Annotation\Route;

/**
 * This is an awesome controller!
 */
#[Route('/my/route')]
final class MyController
{
}
```


Pull request description:
PhpdocToCommentFixer - add support for attributes
closes #5437

Patch:
diff --git a/phpstan.neon b/phpstan.neon
index 18dac5cce..65c2ebf59 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -14,6 +14,7 @@ parameters:
         - '/^Return typehint of method PhpCsFixer\\Tests\\Test\\.+::createIsIdenticalStringConstraint\(\) has invalid type PHPUnit_Framework_Constraint_IsIdentical\.$/'
         - '/^Class (Symfony\\Contracts\\EventDispatcher\\Event|Symfony\\Component\\EventDispatcher\\Event) not found.$/'
         - '/^Constant T_NAME_(RELATIVE|FULLY_QUALIFIED|QUALIFIED) not found\.$/'
+        - '/Instantiated class .*TraversableContains is abstract/'
         - '/assertInstanceOf\(\) expects class-string.*, string given/'
         -
             message: '/^Unsafe usage of new static\(\)\.$/'
diff --git a/src/Fixer/LanguageConstruct/SingleSpaceAfterConstructFixer.php b/src/Fixer/LanguageConstruct/SingleSpaceAfterConstructFixer.php
index 8c432d51d..b82e0223b 100644
--- a/src/Fixer/LanguageConstruct/SingleSpaceAfterConstructFixer.php
+++ b/src/Fixer/LanguageConstruct/SingleSpaceAfterConstructFixer.php
@@ -197,7 +197,7 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens)
 
             $whitespaceTokenIndex = $index + 1;
 
-            if ($tokens[$whitespaceTokenIndex]->equalsAny([';', ')', [CT::T_ARRAY_SQUARE_BRACE_CLOSE]])) {
+            if (';' === $tokens[$whitespaceTokenIndex]->getContent()) {
                 continue;
             }
 
diff --git a/src/Tokenizer/Analyzer/ArgumentsAnalyzer.php b/src/Tokenizer/Analyzer/ArgumentsAnalyzer.php
index 67a68d35d..0904a04fd 100644
--- a/src/Tokenizer/Analyzer/ArgumentsAnalyzer.php
+++ b/src/Tokenizer/Analyzer/ArgumentsAnalyzer.php
@@ -14,7 +14,6 @@
 
 use PhpCsFixer\Tokenizer\Analyzer\Analysis\ArgumentAnalysis;
 use PhpCsFixer\Tokenizer\Analyzer\Analysis\TypeAnalysis;
-use PhpCsFixer\Tokenizer\CT;
 use PhpCsFixer\Tokenizer\Tokens;
 
 /**
@@ -113,12 +112,7 @@ public function getArgumentInfo(Tokens $tokens, $argumentStart, $argumentEnd)
         for ($index = $argumentStart; $index <= $argumentEnd; ++$index) {
             $token = $tokens[$index];
 
-            if (
-                $token->isComment()
-                || $token->isWhitespace()
-                || $token->isGivenKind([T_ELLIPSIS, CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PUBLIC, CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PROTECTED, CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PRIVATE])
-                || $token->equals('&')
-            ) {
+            if ($token->isComment() || $token->isWhitespace() || $token->isGivenKind(T_ELLIPSIS) || $token->equals('&')) {
                 continue;
             }
 
diff --git a/src/Tokenizer/Analyzer/CommentsAnalyzer.php b/src/Tokenizer/Analyzer/CommentsAnalyzer.php
index cd56fca10..58d29bf06 100644
--- a/src/Tokenizer/Analyzer/CommentsAnalyzer.php
+++ b/src/Tokenizer/Analyzer/CommentsAnalyzer.php
@@ -84,6 +84,13 @@ public function isBeforeStructuralElement(Tokens $tokens, $index)
         $nextIndex = $index;
         do {
             $nextIndex = $tokens->getNextMeaningfulToken($nextIndex);
+
+            if (\defined('T_ATTRIBUTE')) {
+                while (null !== $nextIndex && $tokens[$nextIndex]->isGivenKind(T_ATTRIBUTE)) {
+                    $nextIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_ATTRIBUTE, $nextIndex);
+                    $nextIndex = $tokens->getNextMeaningfulToken($nextIndex);
+                }
+            }
         } while (null !== $nextIndex && $tokens[$nextIndex]->equals('('));
 
         if (null === $nextIndex || $tokens[$nextIndex]->equals('}')) {


Test patch:
diff --git a/tests/AutoReview/CiConfigurationTest.php b/tests/AutoReview/CiConfigurationTest.php
index 1b377941e..0d08a437b 100644
--- a/tests/AutoReview/CiConfigurationTest.php
+++ b/tests/AutoReview/CiConfigurationTest.php
@@ -15,7 +15,7 @@
 use PhpCsFixer\Preg;
 use PhpCsFixer\Tests\TestCase;
 use PhpCsFixer\Tokenizer\Tokens;
-use PHPUnit\Framework\Constraint\TraversableContainsIdentical;
+use PHPUnit\Framework\Constraint\TraversableContains;
 use Symfony\Component\Yaml\Yaml;
 
 /**
@@ -74,26 +74,36 @@ public function testDeploymentJobsRunOnLatestStablePhpThatIsSupportedByTool()
         }
     }
 
-    private static function ensureTraversableContainsIdenticalIsAvailable()
+    private static function ensureTraversableContainsIsAvailable()
     {
-        if (!class_exists(TraversableContainsIdentical::class)) {
-            static::markTestSkipped('TraversableContainsIdentical not available.');
+        if (!class_exists(TraversableContains::class)) {
+            static::markTestSkipped('TraversableContains not available.');
+        }
+
+        try {
+            new TraversableContains('');
+        } catch (\Error $e) {
+            if (false === strpos($e->getMessage(), 'Cannot instantiate abstract class')) {
+                throw $e;
+            }
+
+            static::markTestSkipped('TraversableContains not available.');
         }
     }
 
     private static function assertUpcomingPhpVersionIsCoveredByCiJob($lastSupportedVersion, array $ciVersions)
     {
-        self::ensureTraversableContainsIdenticalIsAvailable();
+        self::ensureTraversableContainsIsAvailable();
 
         static::assertThat($ciVersions, static::logicalOr(
             // if `$lastsupportedVersion` is already a snapshot version
-            new TraversableContainsIdentical(sprintf('%.1fsnapshot', $lastSupportedVersion)),
+            new TraversableContains(sprintf('%.1fsnapshot', $lastSupportedVersion)),
             // if `$lastsupportedVersion` is not snapshot version, expect CI to run snapshot of next PHP version
-            new TraversableContainsIdentical('nightly'),
-            new TraversableContainsIdentical(sprintf('%.1fsnapshot', $lastSupportedVersion + 0.1)),
+            new TraversableContains('nightly'),
+            new TraversableContains(sprintf('%.1fsnapshot', $lastSupportedVersion + 0.1)),
             // GitHub CI uses just versions, without suffix, e.g. 8.1 for 8.1snapshot as of writing
-            new TraversableContainsIdentical(sprintf('%.1f', $lastSupportedVersion + 0.1)),
-            new TraversableContainsIdentical(sprintf('%.1f', round($lastSupportedVersion + 1)))
+            new TraversableContains(sprintf('%.1f', $lastSupportedVersion + 0.1)),
+            new TraversableContains(sprintf('%.1f', round($lastSupportedVersion + 1)))
         ));
     }
 
@@ -105,11 +115,11 @@ private static function assertSupportedPhpVersionsAreCoveredByCiJobs(array $supp
             static::assertContains($expectedVersion, $ciVersions);
         }
 
-        self::ensureTraversableContainsIdenticalIsAvailable();
+        self::ensureTraversableContainsIsAvailable();
 
         static::assertThat($ciVersions, static::logicalOr(
-            new TraversableContainsIdentical($lastSupportedVersion),
-            new TraversableContainsIdentical(sprintf('%.1fsnapshot', $lastSupportedVersion))
+            new TraversableContains($lastSupportedVersion),
+            new TraversableContains(sprintf('%.1fsnapshot', $lastSupportedVersion))
         ));
     }
 
diff --git a/tests/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixerTest.php b/tests/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixerTest.php
index b94743d90..581effe85 100644
--- a/tests/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixerTest.php
+++ b/tests/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixerTest.php
@@ -404,13 +404,13 @@ public function provideFixPhp74Cases()
     }
 
     /**
-     * @param string      $expected
-     * @param null|string $input
+     * @param string $expected
+     * @param string $input
      *
      * @dataProvider provideFix80Cases
      * @requires PHP 8.0
      */
-    public function testFix80($expected, $input = null)
+    public function testFix80($expected, $input)
     {
         $this->doTest($expected, $input);
     }
@@ -421,17 +421,5 @@ public function provideFix80Cases()
             '<?php function foo(?string $param = null,) {}',
             '<?php function foo(string $param = null,) {}',
         ];
-
-        yield [
-            '<?php class Foo {
-                public function __construct(
-                    public ?string $paramA = null,
-                    protected ?string $paramB = null,
-                    private ?string $paramC = null,
-                    ?string $paramD = null,
-                    $a = []
-                ) {}
-            }',
-        ];
     }
 }
diff --git a/tests/Fixer/LanguageConstruct/SingleSpaceAfterConstructFixerTest.php b/tests/Fixer/LanguageConstruct/SingleSpaceAfterConstructFixerTest.php
index 2e0899981..5d7aacc14 100644
--- a/tests/Fixer/LanguageConstruct/SingleSpaceAfterConstructFixerTest.php
+++ b/tests/Fixer/LanguageConstruct/SingleSpaceAfterConstructFixerTest.php
@@ -3137,10 +3137,10 @@ public function provideFixWithPhpOpenCases()
     /**
      * @dataProvider provideCommentsCases
      *
-     * @param string      $expected
-     * @param null|string $input
+     * @param string $expected
+     * @param string $input
      */
-    public function testComments($expected, $input = null)
+    public function testComments($expected, $input)
     {
         $this->fixer->configure([
             'constructs' => [
@@ -3170,15 +3170,6 @@ public function provideCommentsCases()
 $a = 3; # 3
 $a = 4; /** 4 */
 echo 1;
-',
-        ];
-
-        yield 'exceptions' => [
-            '<?php
-new Dummy(/* a */);
-new Dummy(/** b */);
-foo(/* c */);
-$arr = [/* empty */];
 ',
         ];
     }
diff --git a/tests/Fixer/Phpdoc/PhpdocToCommentFixerTest.php b/tests/Fixer/Phpdoc/PhpdocToCommentFixerTest.php
index a4cae3d9d..81075adfb 100644
--- a/tests/Fixer/Phpdoc/PhpdocToCommentFixerTest.php
+++ b/tests/Fixer/Phpdoc/PhpdocToCommentFixerTest.php
@@ -713,4 +713,72 @@ class Foo {
             ],
         ];
     }
+
+    /**
+     * @param string      $expected
+     * @param null|string $input
+     *
+     * @dataProvider provideFix80Cases
+     * @requires PHP 8.0
+     */
+    public function testFix80($expected, $input = null)
+    {
+        $this->doTest($expected, $input);
+    }
+
+    public function provideFix80Cases()
+    {
+        return [
+            [
+                '<?php
+/**
+ * @Annotation
+ */
+#[CustomAnnotationA]
+Class MyAnnotation3
+{
+    /**
+     * @Annotation
+     */
+    #[CustomAnnotationB]
+    #[CustomAnnotationC]
+    public function foo() {}
+
+    /**
+     * @Annotation
+     */
+    #[CustomAnnotationD]
+    public $var;
+
+    /*
+     * end of class
+     */
+}',
+                '<?php
+/**
+ * @Annotation
+ */
+#[CustomAnnotationA]
+Class MyAnnotation3
+{
+    /**
+     * @Annotation
+     */
+    #[CustomAnnotationB]
+    #[CustomAnnotationC]
+    public function foo() {}
+
+    /**
+     * @Annotation
+     */
+    #[CustomAnnotationD]
+    public $var;
+
+    /**
+     * end of class
+     */
+}',
+            ],
+        ];
+    }
 }
diff --git a/tests/Tokenizer/Analyzer/ArgumentsAnalyzerTest.php b/tests/Tokenizer/Analyzer/ArgumentsAnalyzerTest.php
index 96c811a03..8b6ab1262 100644
--- a/tests/Tokenizer/Analyzer/ArgumentsAnalyzerTest.php
+++ b/tests/Tokenizer/Analyzer/ArgumentsAnalyzerTest.php
@@ -62,26 +62,6 @@ public function testArgumentInfo($code, $openIndex, $closeIndex, $expected)
         );
     }
 
-    /**
-     * @param string $code
-     * @param int    $openIndex
-     * @param int    $closeIndex
-     * @param array  $expected
-     *
-     * @requires PHP 8.0
-     * @dataProvider provideArgumentsInfo80Cases
-     */
-    public function testArgumentInfo80($code, $openIndex, $closeIndex, $expected)
-    {
-        $tokens = Tokens::fromCode($code);
-        $analyzer = new ArgumentsAnalyzer();
-
-        static::assertSame(
-            serialize($expected),
-            serialize($analyzer->getArgumentInfo($tokens, $openIndex, $closeIndex))
-        );
-    }
-
     public function provideArgumentsCases()
     {
         return [
@@ -182,27 +162,6 @@ public function provideArgumentsInfoCases()
         ];
     }
 
-    public function provideArgumentsInfo80Cases()
-    {
-        foreach (['public', 'protected', 'private'] as $visibility) {
-            yield [
-                sprintf('<?php class Foo { public function __construct(%s ?string $param = null) {} }', $visibility),
-                13,
-                22,
-                new ArgumentAnalysis(
-                    '$param',
-                    18,
-                    'null',
-                    new TypeAnalysis(
-                        '?string',
-                        15,
-                        16
-                    )
-                ),
-            ];
-        }
-    }
-
     /**
      * @param string $code
      * @param int    $openIndex
@@ -231,30 +190,4 @@ public function provideArguments73Cases()
             ['<?php bar($a, $b , ) ;', 2, 10, [3 => 3, 5 => 7]],
         ];
     }
-
-    /**
-     * @param string $code
-     * @param int    $openIndex
-     * @param int    $closeIndex
-     *
-     * @requires PHP 8.0
-     * @dataProvider provideArguments80Cases
-     */
-    public function testArguments80($code, $openIndex, $closeIndex, array $arguments)
-    {
-        $tokens = Tokens::fromCode($code);
-        $analyzer = new ArgumentsAnalyzer();
-
-        static::assertSame(\count($arguments), $analyzer->countArguments($tokens, $openIndex, $closeIndex));
-        static::assertSame($arguments, $analyzer->getArguments($tokens, $openIndex, $closeIndex));
-    }
-
-    public function provideArguments80Cases()
-    {
-        return [
-            ['<?php class Foo { public function __construct(public ?string $param = null) {} }', 12, 23, [13 => 22]],
-            ['<?php class Foo { public function __construct(protected ?string $param = null) {} }', 12, 23, [13 => 22]],
-            ['<?php class Foo { public function __construct(private ?string $param = null) {} }', 12, 23, [13 => 22]],
-        ];
-    }
 }
diff --git a/tests/Tokenizer/Analyzer/CommentsAnalyzerTest.php b/tests/Tokenizer/Analyzer/CommentsAnalyzerTest.php
index 3b5d44e80..52b247dda 100644
--- a/tests/Tokenizer/Analyzer/CommentsAnalyzerTest.php
+++ b/tests/Tokenizer/Analyzer/CommentsAnalyzerTest.php
@@ -340,4 +340,31 @@ public function providePhpdocCandidatePhp74Cases()
             ['<?php /* Before anonymous function */ $fn = fn($x) => $x + 1;'],
         ];
     }
+
+    /**
+     * @param string $code
+     *
+     * @dataProvider providePhpdocCandidatePhp80Cases
+     * @requires PHP 8.0
+     */
+    public function testPhpdocCandidatePhp80($code)
+    {
+        $tokens = Tokens::fromCode($code);
+        $index = $tokens->getNextTokenOfKind(0, [[T_COMMENT], [T_DOC_COMMENT]]);
+        $analyzer = new CommentsAnalyzer();
+
+        static::assertTrue($analyzer->isBeforeStructuralElement($tokens, $index));
+    }
+
+    public function providePhpdocCandidatePhp80Cases()
+    {
+        return [
+            ['<?php
+/**
+ * @Annotation
+ */
+#[CustomAnnotationA]
+Class MyAnnotation3 {}'],
+        ];
+    }
 }