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

Issue:
nullable_type_declaration_for_default_null_value fails for nullable readonly parameter in promoted constructor
I think `NullableTypeDeclarationForDefaultNullValueFixer` crashes when I'm trying to add `readonly` modifier to nullable parameter in promoted constructor. 

## Bug report
   * PHP version: PHP 8.1.0 (cli) (built: Nov 30 2021 07:26:16) (NTS)
   * PHP CS Fixer version: PHP CS Fixer 3.4.0 Si! by Fabien Potencier and Dariusz Ruminski
   * the command used to run PHP CS Fixer (run with `-vvv`):
```
PHP CS Fixer 3.4.0 Si! by Fabien Potencier and Dariusz Ruminski
Runtime: PHP 8.1.0
Loaded config default.
Using cache file ".php-cs-fixer.cache".
E                                                                                                                                                                                                                                                                                                           1 / 1 (100%)
Legend: ?-unknown, I-invalid file syntax (file ignored), S-skipped (cached or empty file), .-no changes, F-fixed, E-error

Fixed all files in 0.004 seconds, 12.000 MB memory used

Files that were not fixed due to errors reported during linting after fixing:
   1) /application/src/ReadonlyThing.php

                                                                            
        [PhpCsFixer\Linter\LintingException]                                
        Parse error: syntax error, unexpected token "readonly" on line 11.  
                                                                            

      Applied fixers: nullable_type_declaration_for_default_null_value
ERROR: 64
```
   * the configuration (file) you used: none


### Code snippet that reproduces the problem
```php
<?php

class ReadonlyThing
{
    public function __construct(
        protected readonly bool $nullable = null,
    ) {
    }
}
```

### Expected output
```php
<?php

class ReadonlyThing
{
    public function __construct(
        protected readonly ?bool $nullable = null,
    ) {
    }
}
```

Btw. when it's used with easy-coding-standard, it's even worse:
```php
<?php

class ReadonlyThing
{
    public function __construct(
        protected ?readonly ?bool $nullable = null,
    ) {
    }
}
```


Discussion:
Thanks for reporting this issue to us.

Looking at your example I wonder why the tool tries to fix the code as it does not lint:

```
$ sudo docker-compose run --rm --user "$(id --user):$(id --group)" php php -l test2.php
Fatal error: Cannot use null as default value for parameter $nullable of type bool in test2.php on line 5
```

```
$ sudo docker-compose run --rm --user "$(id --user):$(id --group)" php php -v
PHP 8.1.0 (cli) (built: Dec  2 2021 12:47:04) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.1.0, Copyright (c) Zend Technologies
```
to clarify; it does "fix" the following correct PHP incorrectly so there is indeed a bug in the tool

```php
<?php
class Foo
{
    public function __construct(
        protected readonly ?bool $nullable = null,
    ) {}
}
```




Patch:
diff --git a/src/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixer.php b/src/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixer.php
index 8aca8026a82..8c5faa8b711 100644
--- a/src/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixer.php
+++ b/src/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixer.php
@@ -98,8 +98,8 @@ protected function createConfigurationDefinition(): FixerConfigurationResolverIn
     protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
     {
         $functionsAnalyzer = new FunctionsAnalyzer();
-
         $tokenKinds = [T_FUNCTION];
+
         if (\PHP_VERSION_ID >= 70400) {
             $tokenKinds[] = T_FN;
         }
@@ -112,7 +112,6 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
             }
 
             $arguments = $functionsAnalyzer->getFunctionArguments($tokens, $index);
-
             $this->fixFunctionParameters($tokens, $arguments);
         }
     }
diff --git a/src/Tokenizer/Analyzer/ArgumentsAnalyzer.php b/src/Tokenizer/Analyzer/ArgumentsAnalyzer.php
index 964f35c1231..39d9c8922ca 100644
--- a/src/Tokenizer/Analyzer/ArgumentsAnalyzer.php
+++ b/src/Tokenizer/Analyzer/ArgumentsAnalyzer.php
@@ -87,6 +87,16 @@ public function getArguments(Tokens $tokens, int $openParenthesis, int $closePar
 
     public function getArgumentInfo(Tokens $tokens, int $argumentStart, int $argumentEnd): ArgumentAnalysis
     {
+        static $skipTypes = null;
+
+        if (null === $skipTypes) {
+            $skipTypes = [T_ELLIPSIS, CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PUBLIC, CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PROTECTED, CT::T_CONSTRUCTOR_PROPERTY_PROMOTION_PRIVATE];
+
+            if (\defined('T_READONLY')) { // @TODO: drop condition when PHP 8.1+ is required
+                $skipTypes[] = T_READONLY;
+            }
+        }
+
         $info = [
             'default' => null,
             'name' => null,
@@ -101,10 +111,16 @@ public function getArgumentInfo(Tokens $tokens, int $argumentStart, int $argumen
         for ($index = $argumentStart; $index <= $argumentEnd; ++$index) {
             $token = $tokens[$index];
 
+            if (\defined('T_ATTRIBUTE') && $token->isGivenKind(T_ATTRIBUTE)) {
+                $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_ATTRIBUTE, $index);
+
+                continue;
+            }
+
             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->isGivenKind($skipTypes)
                 || $token->equals('&')
             ) {
                 continue;
@@ -122,12 +138,6 @@ public function getArgumentInfo(Tokens $tokens, int $argumentStart, int $argumen
                 continue;
             }
 
-            if (\defined('T_ATTRIBUTE') && $token->isGivenKind(T_ATTRIBUTE)) {
-                $index = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_ATTRIBUTE, $index);
-
-                continue;
-            }
-
             if ($sawName) {
                 $info['default'] .= $token->getContent();
             } else {


Test patch:
diff --git a/tests/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixerTest.php b/tests/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixerTest.php
index 9916467b549..c086d0a92b5 100644
--- a/tests/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixerTest.php
+++ b/tests/Fixer/FunctionNotation/NullableTypeDeclarationForDefaultNullValueFixerTest.php
@@ -500,4 +500,27 @@ function foo2(?string &/*comment*/$param2 = null) {}
         yield [$cases[0], $cases[1]];
         yield [$cases[1], $cases[0], ['use_nullable_type_declaration' => false]];
     }
+
+    /**
+     * @dataProvider provideFix81Cases
+     * @requires PHP 8.1
+     */
+    public function testFix81(string $expected): void
+    {
+        $this->doTest($expected);
+    }
+
+    public function provideFix81Cases(): iterable
+    {
+        yield [
+            '<?php
+class Foo
+{
+    public function __construct(
+        protected readonly ?bool $nullable = null,
+    ) {}
+}
+',
+        ];
+    }
 }
diff --git a/tests/Tokenizer/Analyzer/ArgumentsAnalyzerTest.php b/tests/Tokenizer/Analyzer/ArgumentsAnalyzerTest.php
index 085eec10c1b..c3600e07a64 100644
--- a/tests/Tokenizer/Analyzer/ArgumentsAnalyzerTest.php
+++ b/tests/Tokenizer/Analyzer/ArgumentsAnalyzerTest.php
@@ -86,16 +86,14 @@ public function testArguments73(string $code, int $openIndex, int $closeIndex, a
         static::assertSame($arguments, $analyzer->getArguments($tokens, $openIndex, $closeIndex));
     }
 
-    public function provideArguments73Cases(): array
+    public function provideArguments73Cases(): iterable
     {
-        return [
-            ['<?php foo($a,);', 2, 5, [3 => 3]],
-            ['<?php foo($a,/**/);', 2, 6, [3 => 3]],
-            ['<?php foo($a(1,2,3,4,5),);', 2, 16, [3 => 14]],
-            ['<?php foo($a(1,2,3,4,5,),);', 2, 17, [3 => 15]],
-            ['<?php foo($a(1,2,3,4,5,),);', 4, 15, [5 => 5, 7 => 7, 9 => 9, 11 => 11, 13 => 13]],
-            ['<?php bar($a, $b , ) ;', 2, 10, [3 => 3, 5 => 7]],
-        ];
+        yield ['<?php foo($a,);', 2, 5, [3 => 3]];
+        yield ['<?php foo($a,/**/);', 2, 6, [3 => 3]];
+        yield ['<?php foo($a(1,2,3,4,5),);', 2, 16, [3 => 14]];
+        yield ['<?php foo($a(1,2,3,4,5,),);', 2, 17, [3 => 15]];
+        yield ['<?php foo($a(1,2,3,4,5,),);', 4, 15, [5 => 5, 7 => 7, 9 => 9, 11 => 11, 13 => 13]];
+        yield ['<?php bar($a, $b , ) ;', 2, 10, [3 => 3, 5 => 7]];
     }
 
     /**
@@ -146,10 +144,7 @@ public function testArgumentInfo(string $code, int $openIndex, int $closeIndex,
         $tokens = Tokens::fromCode($code);
         $analyzer = new ArgumentsAnalyzer();
 
-        static::assertSame(
-            serialize($expected),
-            serialize($analyzer->getArgumentInfo($tokens, $openIndex, $closeIndex))
-        );
+        self::assertArgumentAnalysis($expected, $analyzer->getArgumentInfo($tokens, $openIndex, $closeIndex));
     }
 
     public function provideArgumentsInfoCases(): \Generator
@@ -284,10 +279,7 @@ public function testArgumentInfo80(string $code, int $openIndex, int $closeIndex
         $tokens = Tokens::fromCode($code);
         $analyzer = new ArgumentsAnalyzer();
 
-        static::assertSame(
-            serialize($expected),
-            serialize($analyzer->getArgumentInfo($tokens, $openIndex, $closeIndex))
-        );
+        self::assertArgumentAnalysis($expected, $analyzer->getArgumentInfo($tokens, $openIndex, $closeIndex));
     }
 
     public function provideArgumentsInfo80Cases(): \Generator
@@ -326,4 +318,66 @@ public function provideArgumentsInfo80Cases(): \Generator
             ];
         }
     }
+
+    /**
+     * @requires PHP 8.1
+     * @dataProvider provideArgumentsInfo81Cases
+     */
+    public function testArgumentInfo81(string $code, int $openIndex, int $closeIndex, ArgumentAnalysis $expected): void
+    {
+        $tokens = Tokens::fromCode($code);
+        $analyzer = new ArgumentsAnalyzer();
+
+        self::assertArgumentAnalysis($expected, $analyzer->getArgumentInfo($tokens, $openIndex, $closeIndex));
+    }
+
+    public function provideArgumentsInfo81Cases(): \Generator
+    {
+        yield [
+            '<?php
+class Foo
+{
+    public function __construct(
+        protected readonly ?bool $nullable = true,
+    ) {}
+}
+',
+            13,
+            25,
+            new ArgumentAnalysis(
+                '$nullable',
+                21,
+                'true',
+                new TypeAnalysis(
+                    '?bool',
+                    18,
+                    19
+                )
+            ),
+        ];
+    }
+
+    private static function assertArgumentAnalysis(ArgumentAnalysis $expected, ArgumentAnalysis $actual): void
+    {
+        static::assertSame($expected->getDefault(), $actual->getDefault(), 'Default.');
+        static::assertSame($expected->getName(), $actual->getName(), 'Name.');
+        static::assertSame($expected->getNameIndex(), $actual->getNameIndex(), 'Name index.');
+        static::assertSame($expected->hasDefault(), $actual->hasDefault(), 'Has default.');
+        static::assertSame($expected->hasTypeAnalysis(), $actual->hasTypeAnalysis(), 'Has type analysis.');
+
+        if ($expected->hasTypeAnalysis()) {
+            $expectedTypeAnalysis = $expected->getTypeAnalysis();
+            $actualTypeAnalysis = $actual->getTypeAnalysis();
+
+            static::assertSame($expectedTypeAnalysis->getEndIndex(), $actualTypeAnalysis->getEndIndex(), 'Type analysis end index.');
+            static::assertSame($expectedTypeAnalysis->getName(), $actualTypeAnalysis->getName(), 'Type analysis name.');
+            static::assertSame($expectedTypeAnalysis->getStartIndex(), $actualTypeAnalysis->getStartIndex(), 'Type analysis start index.');
+            static::assertSame($expectedTypeAnalysis->isNullable(), $actualTypeAnalysis->isNullable(), 'Type analysis nullable.');
+            static::assertSame($expectedTypeAnalysis->isReservedType(), $actualTypeAnalysis->isReservedType(), 'Type analysis reserved type.');
+        } else {
+            static::assertNull($actual->getTypeAnalysis());
+        }
+
+        static::assertSame(serialize($expected), serialize($actual));
+    }
 }