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

Issue:
[PHP8.4] `normalize_index_brace` treats property hook's `{}` as index access
### Problem description

As you can see in #8300, there is a problem with property hooks syntax, which is treated as array index fetch.

[This fixer should work only for PHP <8.0](https://3v4l.org/o8Rdd), but there is no condition within fixer itself. It seems like the root cause of this is `PhpCsFixer\Tokenizer\Transformer\BraceTransformer::transformIntoCurlyIndexBraces()`, which replaces `{` with `CT::T_ARRAY_INDEX_CURLY_BRACE_OPEN`.

### Minimal reproducer

The code like this:

```php
<?php

class PropertyHooks
{
    public string $bar {
        set(string $value) {
            $this -> foo = strtolower($value);
        }
    }
}
```

is malformed into this:

```php
<?php

class PropertyHooks
{
    public string $bar[
        set(string $value) {
        $this->foo = strtolower($value);
    }
    ]
}
```

### Fixer version

3.64.1-DEV

### PHP version

8.4.1

### How do you run Fixer?

Composer package

### Contribution Checks

- [X] I have verified if this problem was already reported
- [X] I am familiar with ["Feature or bug?"](https://github.com/PHP-CS-Fixer/PHP-CS-Fixer/blob/master/feature-or-bug.rst)

Pull request:
feat: Tokenizer - initial support for PHP 8.4 property hooks
closes #8301 

Patch:
diff --git a/src/Tokenizer/CT.php b/src/Tokenizer/CT.php
index a9815fe0e..eefe46d3f 100644
--- a/src/Tokenizer/CT.php
+++ b/src/Tokenizer/CT.php
@@ -58,6 +58,8 @@ final class CT
     public const T_DISJUNCTIVE_NORMAL_FORM_TYPE_PARENTHESIS_CLOSE = 10_037;
     public const T_DYNAMIC_CLASS_CONSTANT_FETCH_CURLY_BRACE_OPEN = 10_038;
     public const T_DYNAMIC_CLASS_CONSTANT_FETCH_CURLY_BRACE_CLOSE = 10_039;
+    public const T_PROPERTY_HOOK_BRACE_OPEN = 10_040;
+    public const T_PROPERTY_HOOK_BRACE_CLOSE = 10_041;
 
     private function __construct() {}
 
diff --git a/src/Tokenizer/Transformer/BraceTransformer.php b/src/Tokenizer/Transformer/BraceTransformer.php
index 85bfb86b6..7bf452176 100644
--- a/src/Tokenizer/Transformer/BraceTransformer.php
+++ b/src/Tokenizer/Transformer/BraceTransformer.php
@@ -28,7 +28,8 @@
  * - in `$foo->{$bar}` into CT::T_DYNAMIC_PROP_BRACE_OPEN and CT::T_DYNAMIC_PROP_BRACE_CLOSE,
  * - in `${$foo}` into CT::T_DYNAMIC_VAR_BRACE_OPEN and CT::T_DYNAMIC_VAR_BRACE_CLOSE,
  * - in `$array{$index}` into CT::T_ARRAY_INDEX_CURLY_BRACE_OPEN and CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE,
- * - in `use some\a\{ClassA, ClassB, ClassC as C}` into CT::T_GROUP_IMPORT_BRACE_OPEN, CT::T_GROUP_IMPORT_BRACE_CLOSE.
+ * - in `use some\a\{ClassA, ClassB, ClassC as C}` into CT::T_GROUP_IMPORT_BRACE_OPEN, CT::T_GROUP_IMPORT_BRACE_CLOSE,
+ * - in `class PropertyHooks { public string $bar _{_ set(string $value) { } _}_` into CT::T_PROPERTY_HOOK_BRACE_OPEN, CT::T_PROPERTY_HOOK_BRACE_CLOSE.
  *
  * @author Dariusz Rumiński <dariusz.ruminski@gmail.com>
  *
@@ -43,13 +44,14 @@ public function getRequiredPhpVersionId(): int
 
     public function process(Tokens $tokens, Token $token, int $index): void
     {
-        $this->transformIntoCurlyCloseBrace($tokens, $token, $index);
-        $this->transformIntoDollarCloseBrace($tokens, $token, $index);
-        $this->transformIntoDynamicPropBraces($tokens, $token, $index);
-        $this->transformIntoDynamicVarBraces($tokens, $token, $index);
-        $this->transformIntoCurlyIndexBraces($tokens, $token, $index);
-        $this->transformIntoGroupUseBraces($tokens, $token, $index);
-        $this->transformIntoDynamicClassConstantFetchBraces($tokens, $token, $index);
+        $this->transformIntoCurlyCloseBrace($tokens, $index);
+        $this->transformIntoDollarCloseBrace($tokens, $index);
+        $this->transformIntoDynamicPropBraces($tokens, $index);
+        $this->transformIntoDynamicVarBraces($tokens, $index);
+        $this->transformIntoPropertyHookBraces($tokens, $index);
+        $this->transformIntoCurlyIndexBraces($tokens, $index);
+        $this->transformIntoGroupUseBraces($tokens, $index);
+        $this->transformIntoDynamicClassConstantFetchBraces($tokens, $index);
     }
 
     public function getCustomTokens(): array
@@ -67,6 +69,8 @@ public function getCustomTokens(): array
             CT::T_GROUP_IMPORT_BRACE_CLOSE,
             CT::T_DYNAMIC_CLASS_CONSTANT_FETCH_CURLY_BRACE_OPEN,
             CT::T_DYNAMIC_CLASS_CONSTANT_FETCH_CURLY_BRACE_CLOSE,
+            CT::T_PROPERTY_HOOK_BRACE_OPEN,
+            CT::T_PROPERTY_HOOK_BRACE_CLOSE,
         ];
     }
 
@@ -75,8 +79,10 @@ public function getCustomTokens(): array
      *
      * This should be done at very beginning of curly braces transformations.
      */
-    private function transformIntoCurlyCloseBrace(Tokens $tokens, Token $token, int $index): void
+    private function transformIntoCurlyCloseBrace(Tokens $tokens, int $index): void
     {
+        $token = $tokens[$index];
+
         if (!$token->isGivenKind(T_CURLY_OPEN)) {
             return;
         }
@@ -96,16 +102,20 @@ private function transformIntoCurlyCloseBrace(Tokens $tokens, Token $token, int
         $tokens[$index] = new Token([CT::T_CURLY_CLOSE, '}']);
     }
 
-    private function transformIntoDollarCloseBrace(Tokens $tokens, Token $token, int $index): void
+    private function transformIntoDollarCloseBrace(Tokens $tokens, int $index): void
     {
+        $token = $tokens[$index];
+
         if ($token->isGivenKind(T_DOLLAR_OPEN_CURLY_BRACES)) {
             $nextIndex = $tokens->getNextTokenOfKind($index, ['}']);
             $tokens[$nextIndex] = new Token([CT::T_DOLLAR_CLOSE_CURLY_BRACES, '}']);
         }
     }
 
-    private function transformIntoDynamicPropBraces(Tokens $tokens, Token $token, int $index): void
+    private function transformIntoDynamicPropBraces(Tokens $tokens, int $index): void
     {
+        $token = $tokens[$index];
+
         if (!$token->isObjectOperator()) {
             return;
         }
@@ -121,8 +131,10 @@ private function transformIntoDynamicPropBraces(Tokens $tokens, Token $token, in
         $tokens[$closeIndex] = new Token([CT::T_DYNAMIC_PROP_BRACE_CLOSE, '}']);
     }
 
-    private function transformIntoDynamicVarBraces(Tokens $tokens, Token $token, int $index): void
+    private function transformIntoDynamicVarBraces(Tokens $tokens, int $index): void
     {
+        $token = $tokens[$index];
+
         if (!$token->equals('$')) {
             return;
         }
@@ -145,8 +157,46 @@ private function transformIntoDynamicVarBraces(Tokens $tokens, Token $token, int
         $tokens[$closeIndex] = new Token([CT::T_DYNAMIC_VAR_BRACE_CLOSE, '}']);
     }
 
-    private function transformIntoCurlyIndexBraces(Tokens $tokens, Token $token, int $index): void
+    private function transformIntoPropertyHookBraces(Tokens $tokens, int $index): void
     {
+        if (\PHP_VERSION_ID < 8_04_00) {
+            return; // @TODO: drop condition when PHP 8.4+ is required or majority of the users are using 8.4+
+        }
+
+        $token = $tokens[$index];
+
+        if (!$token->equals('{')) {
+            return;
+        }
+
+        $nextIndex = $tokens->getNextMeaningfulToken($index);
+
+        // @TODO: drop condition when PHP 8.0+ is required
+        if (\defined('T_ATTRIBUTE')) {
+            // skip attributes
+            while ($tokens[$nextIndex]->isGivenKind(T_ATTRIBUTE)) {
+                $nextIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_ATTRIBUTE, $nextIndex);
+                $nextIndex = $tokens->getNextMeaningfulToken($nextIndex);
+            }
+        }
+
+        if (!$tokens[$nextIndex]->equalsAny([
+            [T_STRING, 'get'],
+            [T_STRING, 'set'],
+        ])) {
+            return;
+        }
+
+        $closeIndex = $this->naivelyFindCurlyBlockEnd($tokens, $index);
+
+        $tokens[$index] = new Token([CT::T_PROPERTY_HOOK_BRACE_OPEN, '{']);
+        $tokens[$closeIndex] = new Token([CT::T_PROPERTY_HOOK_BRACE_CLOSE, '}']);
+    }
+
+    private function transformIntoCurlyIndexBraces(Tokens $tokens, int $index): void
+    {
+        $token = $tokens[$index];
+
         if (!$token->equals('{')) {
             return;
         }
@@ -185,8 +235,10 @@ private function transformIntoCurlyIndexBraces(Tokens $tokens, Token $token, int
         $tokens[$closeIndex] = new Token([CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE, '}']);
     }
 
-    private function transformIntoGroupUseBraces(Tokens $tokens, Token $token, int $index): void
+    private function transformIntoGroupUseBraces(Tokens $tokens, int $index): void
     {
+        $token = $tokens[$index];
+
         if (!$token->equals('{')) {
             return;
         }
@@ -203,12 +255,14 @@ private function transformIntoGroupUseBraces(Tokens $tokens, Token $token, int $
         $tokens[$closeIndex] = new Token([CT::T_GROUP_IMPORT_BRACE_CLOSE, '}']);
     }
 
-    private function transformIntoDynamicClassConstantFetchBraces(Tokens $tokens, Token $token, int $index): void
+    private function transformIntoDynamicClassConstantFetchBraces(Tokens $tokens, int $index): void
     {
         if (\PHP_VERSION_ID < 8_03_00) {
             return; // @TODO: drop condition when PHP 8.3+ is required or majority of the users are using 8.3+
         }
 
+        $token = $tokens[$index];
+
         if (!$token->equals('{')) {
             return;
         }


Test patch:
diff --git a/tests/Tokenizer/Transformer/BraceTransformerTest.php b/tests/Tokenizer/Transformer/BraceTransformerTest.php
index 35b405ba5..a9914755d 100644
--- a/tests/Tokenizer/Transformer/BraceTransformerTest.php
+++ b/tests/Tokenizer/Transformer/BraceTransformerTest.php
@@ -52,6 +52,8 @@ public function testProcess(string $source, array $expectedTokens = []): void
                 CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE,
                 CT::T_GROUP_IMPORT_BRACE_OPEN,
                 CT::T_GROUP_IMPORT_BRACE_CLOSE,
+                CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                CT::T_PROPERTY_HOOK_BRACE_CLOSE,
             ]
         );
     }
@@ -185,6 +187,8 @@ public function testProcess80(string $source, array $expectedTokens = []): void
                 CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE,
                 CT::T_GROUP_IMPORT_BRACE_OPEN,
                 CT::T_GROUP_IMPORT_BRACE_CLOSE,
+                CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                CT::T_PROPERTY_HOOK_BRACE_CLOSE,
             ]
         );
     }
@@ -225,6 +229,8 @@ public function testPre84Process(string $source, array $expectedTokens = []): vo
                 CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE,
                 CT::T_GROUP_IMPORT_BRACE_OPEN,
                 CT::T_GROUP_IMPORT_BRACE_CLOSE,
+                CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                CT::T_PROPERTY_HOOK_BRACE_CLOSE,
             ]
         );
     }
@@ -296,6 +302,201 @@ public static function providePre84ProcessCases(): iterable
         ];
     }
 
+    /**
+     * @param _TransformerTestExpectedTokens $expectedTokens
+     *
+     * @dataProvider provideStarting84ProcessCases
+     *
+     * @requires PHP 8.4
+     */
+    public function testStarting84Process(string $source, array $expectedTokens = []): void
+    {
+        $this->doTest(
+            $source,
+            $expectedTokens,
+            [
+                T_CURLY_OPEN,
+                CT::T_CURLY_CLOSE,
+                T_DOLLAR_OPEN_CURLY_BRACES,
+                CT::T_DOLLAR_CLOSE_CURLY_BRACES,
+                CT::T_DYNAMIC_PROP_BRACE_OPEN,
+                CT::T_DYNAMIC_PROP_BRACE_CLOSE,
+                CT::T_DYNAMIC_VAR_BRACE_OPEN,
+                CT::T_DYNAMIC_VAR_BRACE_CLOSE,
+                CT::T_ARRAY_INDEX_CURLY_BRACE_OPEN,
+                CT::T_ARRAY_INDEX_CURLY_BRACE_CLOSE,
+                CT::T_GROUP_IMPORT_BRACE_OPEN,
+                CT::T_GROUP_IMPORT_BRACE_CLOSE,
+                CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ]
+        );
+    }
+
+    /**
+     * @return iterable<array{string, array<int, int>}>
+     */
+    public static function provideStarting84ProcessCases(): iterable
+    {
+        yield 'property hooks: property without default value' => [
+            <<<'PHP'
+                <?php
+                class PropertyHooks
+                {
+                    public string $bar { // << this one
+                        set(string $value) {
+                            $this->bar = strtolower($value);
+                        }
+                    } // << this one
+                }
+                PHP,
+            [
+                13 => CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                40 => CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ],
+        ];
+
+        yield 'property hooks: property with default value (string)' => [
+            <<<'PHP'
+                <?php
+                class PropertyHooks
+                {
+                    public string $bar = "example" { // << this one
+                        set(string $value) {
+                            $this->bar = strtolower($value);
+                        }
+                    } // << this one
+                }
+                PHP,
+            [
+                17 => CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                44 => CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ],
+        ];
+
+        yield 'property hooks: property with default value (array)' => [
+            <<<'PHP'
+                <?php
+                class PropertyHooks
+                {
+                    public $bar = [1,2,3] { // << this one
+                        set($value) {
+                            $this->bar = $value;
+                        }
+                    } // << this one
+                }
+                PHP,
+            [
+                21 => CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                43 => CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ],
+        ];
+
+        yield 'property hooks: property with default value (namespaced)' => [
+            <<<'PHP'
+                <?php
+                class PropertyHooks
+                {
+                    public $bar = DateTimeInterface::ISO8601 { // << this one
+                        set($value) {
+                            $this->bar = $value;
+                        }
+                    } // << this one
+                }
+                PHP,
+            [
+                17 => CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                39 => CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ],
+        ];
+
+        yield 'property hooks: property with setter attributes' => [
+            <<<'PHP'
+                <?php
+                class PropertyHooks
+                {
+                    public string $bar { // << this one
+                        #[A]
+                        #[B]
+                        set(string $value) {
+                            $this->bar = strtolower($value);
+                        }
+                    } // << this one
+                }
+                PHP,
+            [
+                13 => CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                48 => CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ],
+        ];
+
+        yield 'property hooks: property with short setter' => [
+            <<<'PHP'
+                <?php
+                class PropertyHooks
+                {
+                    public string $bar { // << this one
+                        set {
+                            $this->bar = strtolower($value);
+                        }
+                    } // << this one
+                }
+                PHP,
+            [
+                13 => CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                35 => CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ],
+        ];
+
+        yield 'property hooks: property with short getter' => [
+            <<<'PHP'
+                <?php
+                class PropertyHooks
+                {
+                    public string $bar { // << this one
+                        get => ucwords(mb_strtolower($this->bar));
+                    } // << this one
+                }
+                PHP,
+            [
+                13 => CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                32 => CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ],
+        ];
+
+        yield 'property hooks: some more curly braces within hook' => [
+            <<<'PHP'
+                <?php
+                class PropertyHooks
+                {
+                    public $callable { // << this one
+                        set($value) {
+                            if (is_callable($value)) {
+                                $this->callable = $value;
+                            } else {
+                                $this->callable = static function (): void {
+                                    $foo = new class implements \Stringable {
+                                        public function __toString(): string {
+                                            echo 'Na';
+                                        }
+                                    };
+
+                                    for ($i = 0; $i < 8; $i++) {
+                                        echo (string) $foo;
+                                    }
+                                };
+                            }
+                        }
+                    } // << this one
+                }
+                PHP,
+            [
+                11 => CT::T_PROPERTY_HOOK_BRACE_OPEN,
+                143 => CT::T_PROPERTY_HOOK_BRACE_CLOSE,
+            ],
+        ];
+    }
+
     /**
      * @dataProvider provideNotDynamicClassConstantFetchCases
      */