Browse Source

feat: Introduce `AttributeAnalysis` (#7909)

Co-authored-by: Greg Korba <greg@codito.dev>

The concept implemented in this PR was proposed and discussed here:
https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7395#discussion_r1403785945
https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7395#discussion_r1427448017
https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/pull/7395#discussion_r1447323404
HypeMC 11 months ago
parent
commit
7eb5ec3b5a

+ 73 - 0
src/Tokenizer/Analyzer/Analysis/AttributeAnalysis.php

@@ -0,0 +1,73 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * 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\Tokenizer\Analyzer\Analysis;
+
+/**
+ * @internal
+ *
+ * @phpstan-type _AttributeItems list<array{start: int, end: int, name: string}>
+ */
+final class AttributeAnalysis
+{
+    private int $startIndex;
+    private int $endIndex;
+    private int $openingBracketIndex;
+    private int $closingBracketIndex;
+
+    /**
+     * @var _AttributeItems
+     */
+    private array $attributes;
+
+    /**
+     * @param _AttributeItems $attributes
+     */
+    public function __construct(int $startIndex, int $endIndex, int $openingBracketIndex, int $closingBracketIndex, array $attributes)
+    {
+        $this->startIndex = $startIndex;
+        $this->endIndex = $endIndex;
+        $this->openingBracketIndex = $openingBracketIndex;
+        $this->closingBracketIndex = $closingBracketIndex;
+        $this->attributes = $attributes;
+    }
+
+    public function getStartIndex(): int
+    {
+        return $this->startIndex;
+    }
+
+    public function getEndIndex(): int
+    {
+        return $this->endIndex;
+    }
+
+    public function getOpeningBracketIndex(): int
+    {
+        return $this->openingBracketIndex;
+    }
+
+    public function getClosingBracketIndex(): int
+    {
+        return $this->closingBracketIndex;
+    }
+
+    /**
+     * @return _AttributeItems
+     */
+    public function getAttributes(): array
+    {
+        return $this->attributes;
+    }
+}

+ 109 - 0
src/Tokenizer/Analyzer/AttributeAnalyzer.php

@@ -14,11 +14,15 @@ declare(strict_types=1);
 
 namespace PhpCsFixer\Tokenizer\Analyzer;
 
+use PhpCsFixer\Preg;
+use PhpCsFixer\Tokenizer\Analyzer\Analysis\AttributeAnalysis;
 use PhpCsFixer\Tokenizer\CT;
 use PhpCsFixer\Tokenizer\Tokens;
 
 /**
  * @internal
+ *
+ * @phpstan-import-type _AttributeItems from AttributeAnalysis
  */
 final class AttributeAnalyzer
 {
@@ -67,4 +71,109 @@ final class AttributeAnalyzer
 
         return 0 === $count;
     }
+
+    /**
+     * Find all consecutive elements that start with #[ and end with ] and the attributes inside.
+     *
+     * @return list<AttributeAnalysis>
+     */
+    public static function collect(Tokens $tokens, int $index): array
+    {
+        if (!$tokens[$index]->isGivenKind(T_ATTRIBUTE)) {
+            throw new \InvalidArgumentException('Given index must point to an attribute.');
+        }
+
+        // Rewind to first attribute in group
+        while ($tokens[$prevIndex = $tokens->getPrevMeaningfulToken($index)]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
+            $index = $tokens->findBlockStart(Tokens::BLOCK_TYPE_ATTRIBUTE, $prevIndex);
+        }
+
+        /** @var list<AttributeAnalysis> $elements */
+        $elements = [];
+
+        $openingIndex = $index;
+        do {
+            $elements[] = $element = self::collectOne($tokens, $openingIndex);
+            $openingIndex = $tokens->getNextMeaningfulToken($element->getEndIndex());
+        } while ($tokens[$openingIndex]->isGivenKind(T_ATTRIBUTE));
+
+        return $elements;
+    }
+
+    /**
+     * Find one element that starts with #[ and ends with ] and the attributes inside.
+     */
+    public static function collectOne(Tokens $tokens, int $index): AttributeAnalysis
+    {
+        if (!$tokens[$index]->isGivenKind(T_ATTRIBUTE)) {
+            throw new \InvalidArgumentException('Given index must point to an attribute.');
+        }
+
+        $startIndex = $index;
+        if ($tokens[$prevIndex = $tokens->getPrevMeaningfulToken($index)]->isGivenKind(CT::T_ATTRIBUTE_CLOSE)) {
+            // Include comments/PHPDoc if they are present
+            $startIndex = $tokens->getNextNonWhitespace($prevIndex);
+        }
+
+        $closingIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_ATTRIBUTE, $index);
+        $endIndex = $tokens->getNextNonWhitespace($closingIndex);
+
+        return new AttributeAnalysis(
+            $startIndex,
+            $endIndex - 1,
+            $index,
+            $closingIndex,
+            self::collectAttributes($tokens, $index, $closingIndex),
+        );
+    }
+
+    /**
+     * @return _AttributeItems
+     */
+    private static function collectAttributes(Tokens $tokens, int $index, int $closingIndex): array
+    {
+        /** @var _AttributeItems $elements */
+        $elements = [];
+
+        do {
+            $attributeStartIndex = $index + 1;
+
+            $nameStartIndex = $tokens->getNextTokenOfKind($index, [[T_STRING], [T_NS_SEPARATOR]]);
+            $index = $tokens->getNextTokenOfKind($attributeStartIndex, ['(', ',', [CT::T_ATTRIBUTE_CLOSE]]);
+            $attributeName = $tokens->generatePartialCode($nameStartIndex, $tokens->getPrevMeaningfulToken($index));
+
+            // Find closing parentheses, we need to do this in case there's a comma inside the parentheses
+            if ($tokens[$index]->equals('(')) {
+                $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $index);
+                $index = $tokens->getNextTokenOfKind($index, [',', [CT::T_ATTRIBUTE_CLOSE]]);
+            }
+
+            $elements[] = [
+                'start' => $attributeStartIndex,
+                'end' => $index - 1,
+                'name' => $attributeName,
+            ];
+
+            $nextIndex = $index;
+
+            // In case there's a comma right before T_ATTRIBUTE_CLOSE
+            if ($nextIndex < $closingIndex) {
+                $nextIndex = $tokens->getNextMeaningfulToken($index);
+            }
+        } while ($nextIndex < $closingIndex);
+
+        // End last element at newline if it exists and there's no trailing comma
+        --$index;
+        while ($tokens[$index]->isWhitespace()) {
+            if (Preg::match('/\R/', $tokens[$index]->getContent())) {
+                $lastElementKey = array_key_last($elements);
+                $elements[$lastElementKey]['end'] = $index - 1;
+
+                break;
+            }
+            --$index;
+        }
+
+        return $elements;
+    }
 }

+ 41 - 0
tests/Tokenizer/Analyzer/Analysis/AttributeAnalysisTest.php

@@ -0,0 +1,41 @@
+<?php
+
+declare(strict_types=1);
+
+/*
+ * 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\Tokenizer\Analyzer\Analysis;
+
+use PhpCsFixer\Tests\TestCase;
+use PhpCsFixer\Tokenizer\Analyzer\Analysis\AttributeAnalysis;
+
+/**
+ * @covers \PhpCsFixer\Tokenizer\Analyzer\Analysis\AttributeAnalysis
+ *
+ * @internal
+ */
+final class AttributeAnalysisTest extends TestCase
+{
+    public function testAttribute(): void
+    {
+        $attributes = [
+            ['start' => 3, 'end' => 12, 'name' => 'AB\\Baz'],
+            ['start' => 14, 'end' => 32, 'name' => '\\A\\B\\Qux'],
+        ];
+        $analysis = new AttributeAnalysis(2, 34, 3, 34, $attributes);
+
+        self::assertSame(2, $analysis->getStartIndex());
+        self::assertSame(34, $analysis->getEndIndex());
+        self::assertSame(3, $analysis->getOpeningBracketIndex());
+        self::assertSame(34, $analysis->getClosingBracketIndex());
+        self::assertSame($attributes, $analysis->getAttributes());
+    }
+}

+ 294 - 0
tests/Tokenizer/Analyzer/AttributeAnalyzerTest.php

@@ -15,7 +15,9 @@ declare(strict_types=1);
 namespace PhpCsFixer\Tests\Tokenizer\Analyzer;
 
 use PhpCsFixer\Tests\TestCase;
+use PhpCsFixer\Tokenizer\Analyzer\Analysis\AttributeAnalysis;
 use PhpCsFixer\Tokenizer\Analyzer\AttributeAnalyzer;
+use PhpCsFixer\Tokenizer\CT;
 use PhpCsFixer\Tokenizer\Tokens;
 
 /**
@@ -129,4 +131,296 @@ final class AttributeAnalyzerTest extends TestCase
 
         yield [true, '<?php #[Foo("(")] class Bar {}'];
     }
+
+    /**
+     * @requires PHP 8.0
+     *
+     * @dataProvider provideGetAttributeDeclarationsCases
+     *
+     * @param list<AttributeAnalysis> $expectedAnalyses
+     */
+    public function testGetAttributeDeclarations(string $code, int $startIndex, array $expectedAnalyses): void
+    {
+        $tokens = Tokens::fromCode($code);
+        $actualAnalyses = AttributeAnalyzer::collect($tokens, $startIndex);
+
+        foreach ($expectedAnalyses as $expectedAnalysis) {
+            self::assertSame(T_ATTRIBUTE, $tokens[$expectedAnalysis->getOpeningBracketIndex()]->getId());
+            self::assertSame(CT::T_ATTRIBUTE_CLOSE, $tokens[$expectedAnalysis->getClosingBracketIndex()]->getId());
+        }
+
+        self::assertSame(
+            serialize($expectedAnalyses),
+            serialize($actualAnalyses),
+        );
+    }
+
+    /**
+     * @return iterable<array{0: string, 1: int, 2: list<AttributeAnalysis>}>
+     */
+    public static function provideGetAttributeDeclarationsCases(): iterable
+    {
+        yield 'multiple #[] in a multiline group' => [
+            '<?php
+            /**
+             * Start docblock
+             */
+            #[AB\Baz(prop: \'baz\')]
+            #[A\B\Quux(prop1: [1, 2, 4], prop2: true, prop3: \'foo bar\')]
+            #[\A\B\Qux()]
+            #[BarAlias(3)]
+            /**
+             * Corge docblock
+             */
+            #[Corge]
+            #[Foo(4, \'baz qux\')]
+            /**
+             * End docblock
+             */
+            function foo() {}
+            ',
+            4,
+            [
+                new AttributeAnalysis(4, 15, 4, 14, [[
+                    'start' => 5,
+                    'end' => 13,
+                    'name' => 'AB\\Baz',
+                ]]),
+                new AttributeAnalysis(16, 49, 16, 48, [[
+                    'start' => 17,
+                    'end' => 47,
+                    'name' => 'A\\B\\Quux',
+                ]]),
+                new AttributeAnalysis(50, 60, 50, 59, [[
+                    'start' => 51,
+                    'end' => 58,
+                    'name' => '\\A\\B\\Qux',
+                ]]),
+                new AttributeAnalysis(61, 67, 61, 66, [[
+                    'start' => 62,
+                    'end' => 65,
+                    'name' => 'BarAlias',
+                ]]),
+                new AttributeAnalysis(68, 73, 70, 72, [[
+                    'start' => 71,
+                    'end' => 71,
+                    'name' => 'Corge',
+                ]]),
+                new AttributeAnalysis(74, 83, 74, 82, [[
+                    'start' => 75,
+                    'end' => 81,
+                    'name' => 'Foo',
+                ]]),
+            ],
+        ];
+
+        yield 'multiple #[] in a single line group' => [
+            '<?php
+            /** Start docblock */#[AB\Baz(prop: \'baz\')] #[A\B\Quux(prop1: [1, 2, 4], prop2: true, prop3: \'foo bar\')] #[\A\B\Qux()] #[BarAlias(3)] /** Corge docblock */#[Corge] #[Foo(4, \'baz qux\')]/** End docblock */
+            function foo() {}
+            ',
+            3,
+            [
+                new AttributeAnalysis(3, 14, 3, 13, [[
+                    'start' => 4,
+                    'end' => 12,
+                    'name' => 'AB\\Baz',
+                ]]),
+                new AttributeAnalysis(15, 48, 15, 47, [[
+                    'start' => 16,
+                    'end' => 46,
+                    'name' => 'A\\B\\Quux',
+                ]]),
+                new AttributeAnalysis(49, 59, 49, 58, [[
+                    'start' => 50,
+                    'end' => 57,
+                    'name' => '\\A\\B\\Qux',
+                ]]),
+                new AttributeAnalysis(60, 66, 60, 65, [[
+                    'start' => 61,
+                    'end' => 64,
+                    'name' => 'BarAlias',
+                ]]),
+                new AttributeAnalysis(67, 71, 68, 70, [[
+                    'start' => 69,
+                    'end' => 69,
+                    'name' => 'Corge',
+                ]]),
+                new AttributeAnalysis(72, 80, 72, 80, [[
+                    'start' => 73,
+                    'end' => 79,
+                    'name' => 'Foo',
+                ]]),
+            ],
+        ];
+
+        yield 'comma-separated attributes in a multiline #[]' => [
+            '<?php
+            #[
+                /*
+                 * AB\Baz comment
+                 */
+                AB\Baz(prop: \'baz\'),
+                A\B\Quux(prop1: [1, 2, 4], prop2: true, prop3: \'foo bar\'),
+                \A\B\Qux(),
+                BarAlias(3),
+                /*
+                 * Corge comment
+                 */
+                Corge,
+                /**
+                 * Foo docblock
+                 */
+                Foo(4, \'baz qux\'),
+            ]
+            function foo() {}
+            ',
+            2,
+            [
+                new AttributeAnalysis(2, 83, 2, 82, [[
+                    'start' => 3,
+                    'end' => 14,
+                    'name' => 'AB\\Baz',
+                ], [
+                    'start' => 16,
+                    'end' => 47,
+                    'name' => 'A\\B\\Quux',
+                ], [
+                    'start' => 49,
+                    'end' => 57,
+                    'name' => '\\A\\B\\Qux',
+                ], [
+                    'start' => 59,
+                    'end' => 63,
+                    'name' => 'BarAlias',
+                ], [
+                    'start' => 65,
+                    'end' => 68,
+                    'name' => 'Corge',
+                ], [
+                    'start' => 70,
+                    'end' => 79,
+                    'name' => 'Foo',
+                ]]),
+            ],
+        ];
+
+        yield 'comma-separated attributes in a single line #[]' => [
+            '<?php
+            #[/* AB\Baz comment */AB\Baz(prop: \'baz\'), A\B\Quux(prop1: [1, 2, 4], prop2: true, prop3: \'foo bar\'), \A\B\Qux(), BarAlias(3), /* Corge comment */Corge, /** Foo docblock */Foo(4, \'baz qux\')]
+            function foo() {}
+            ',
+            2,
+            [
+                new AttributeAnalysis(2, 77, 2, 76, [[
+                    'start' => 3,
+                    'end' => 12,
+                    'name' => 'AB\\Baz',
+                ], [
+                    'start' => 14,
+                    'end' => 45,
+                    'name' => 'A\\B\\Quux',
+                ], [
+                    'start' => 47,
+                    'end' => 55,
+                    'name' => '\\A\\B\\Qux',
+                ], [
+                    'start' => 57,
+                    'end' => 61,
+                    'name' => 'BarAlias',
+                ], [
+                    'start' => 63,
+                    'end' => 65,
+                    'name' => 'Corge',
+                ], [
+                    'start' => 67,
+                    'end' => 75,
+                    'name' => 'Foo',
+                ]]),
+            ],
+        ];
+    }
+
+    /**
+     * @requires PHP 8.1
+     *
+     * @dataProvider provideGetAttributeDeclarations81Cases
+     *
+     * @param list<AttributeAnalysis> $expectedAnalyses
+     */
+    public function testGetAttributeDeclarations81(string $code, int $startIndex, array $expectedAnalyses): void
+    {
+        $tokens = Tokens::fromCode($code);
+        $actualAnalyses = AttributeAnalyzer::collect($tokens, $startIndex);
+
+        foreach ($expectedAnalyses as $expectedAnalysis) {
+            self::assertSame(T_ATTRIBUTE, $tokens[$expectedAnalysis->getOpeningBracketIndex()]->getId());
+            self::assertSame(CT::T_ATTRIBUTE_CLOSE, $tokens[$expectedAnalysis->getClosingBracketIndex()]->getId());
+        }
+
+        self::assertSame(
+            serialize($expectedAnalyses),
+            serialize($actualAnalyses),
+        );
+    }
+
+    /**
+     * @return iterable<array{0: string, 1: int, 2: list<AttributeAnalysis>}>
+     */
+    public static function provideGetAttributeDeclarations81Cases(): iterable
+    {
+        yield 'multiple #[] in a group, including `new` in arguments' => [
+            '<?php
+                #[AB\Baz(prop: \'baz\')]
+                #[\A\B\Qux(prop: new P\R())]
+                #[Corge]
+                function foo() {}
+                ',
+            2,
+            [
+                new AttributeAnalysis(2, 13, 2, 12, [[
+                    'start' => 3,
+                    'end' => 11,
+                    'name' => 'AB\\Baz',
+                ]]),
+                new AttributeAnalysis(14, 34, 14, 33, [[
+                    'start' => 15,
+                    'end' => 32,
+                    'name' => '\\A\\B\\Qux',
+                ]]),
+                new AttributeAnalysis(35, 38, 35, 37, [[
+                    'start' => 36,
+                    'end' => 36,
+                    'name' => 'Corge',
+                ]]),
+            ],
+        ];
+
+        yield 'comma-separated attributes in single #[] group, including `new` in arguments' => [
+            '<?php
+             #[
+                 AB\Baz(prop: \'baz\'),
+                 \A\B\Qux(prop: new P\R()),
+                 Corge,
+             ]
+             function foo() {}
+             ',
+            2,
+            [
+                new AttributeAnalysis(2, 39, 2, 38, [[
+                    'start' => 3,
+                    'end' => 12,
+                    'name' => 'AB\\Baz',
+                ], [
+                    'start' => 14,
+                    'end' => 32,
+                    'name' => '\\A\\B\\Qux',
+                ], [
+                    'start' => 34,
+                    'end' => 35,
+                    'name' => 'Corge',
+                ]]),
+            ],
+        ];
+    }
 }