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

Issue:
Rule `phpdoc_align` doesn't work with both @method static and not
## Bug report

php version : `8.0.9 (built: Jul 30 2021 13:03:39)`
php-cs-fixer version : `3.0.2`

command :
```bash
$ php vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.php --allow-risky=yes --dry-run --diff -vvv
PHP CS Fixer 3.0.2 Constitution by Fabien Potencier and Dariusz Ruminski
Runtime: PHP 8.0.9
Loaded config default from ".php-cs-fixer.php".
Using cache file ".php-cs-fixer.cache".
SSSSSSSSSFSFSSSSSSSSFSSFSSSSSSSSSSSSSFFFFFSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSSS                                                                                                                                                            80 / 80 (100%)
Legend: ?-unknown, I-invalid file syntax (file ignored), S-skipped (cached or empty file), .-no changes, F-fixed, E-error

   6) src/Infrastructure/Faker/Factory/CustomerFactory.php (phpdoc_align)
      ---------- begin diff ----------
--- /home/me/Documents/Projets/project/src/Infrastructure/Faker/Factory/CustomerFactory.php
+++ /home/me/Documents/Projets/project/src/Infrastructure/Faker/Factory/CustomerFactory.php
@@ -11,19 +11,19 @@
 use Zenstruck\Foundry\RepositoryProxy;
 
 /**
- * @method static Customer|Proxy createOne(array $attributes = [])
- * @method static Customer[]|Proxy[] createMany(int $number, $attributes = [])
- * @method static Customer|Proxy find($criteria)
- * @method static Customer|Proxy findOrCreate(array $attributes)
- * @method static Customer|Proxy first(string $sortedField = 'id')
- * @method static Customer|Proxy last(string $sortedField = 'id')
- * @method static Customer|Proxy random(array $attributes = [])
- * @method static Customer|Proxy randomOrCreate(array $attributes = [])
- * @method static Customer[]|Proxy[] all()
- * @method static Customer[]|Proxy[] findBy(array $attributes)
- * @method static Customer[]|Proxy[] randomSet(int $number, array $attributes = [])
- * @method static Customer[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
- * @method static CustomerRepository|RepositoryProxy repository()
+ * @method static         Customer|Proxy createOne(array $attributes = [])
+ * @method static         Customer[]|Proxy[] createMany(int $number, $attributes = [])
+ * @method static         Customer|Proxy find($criteria)
+ * @method static         Customer|Proxy findOrCreate(array $attributes)
+ * @method static         Customer|Proxy first(string $sortedField = 'id')
+ * @method static         Customer|Proxy last(string $sortedField = 'id')
+ * @method static         Customer|Proxy random(array $attributes = [])
+ * @method static         Customer|Proxy randomOrCreate(array $attributes = [])
+ * @method static         Customer[]|Proxy[] all()
+ * @method static         Customer[]|Proxy[] findBy(array $attributes)
+ * @method static         Customer[]|Proxy[] randomSet(int $number, array $attributes = [])
+ * @method static         Customer[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
+ * @method static         CustomerRepository|RepositoryProxy repository()
  * @method Customer|Proxy create($attributes = [])
  */
 class CustomerFactory extends ModelFactory

      ----------- end diff -----------

```

config file : 
```php
<?php

$finder = PhpCsFixer\Finder::create()
    ->in(__DIR__ . "/src");

$config = new PhpCsFixer\Config();
return $config
    ->setRules([
        '@Symfony' => true,
        '@Symfony:risky' => true,
        '@PSR12' => true,
        '@PHP80Migration' => true,
        '@PHP80Migration:risky' => true,
        'phpdoc_to_comment' => false,
        'strict_param' => true,
        'array_syntax' => ['syntax' => 'short'],
    ])
    ->setFinder($finder);
```

### Code snippet that reproduces the problem

  ```php
<?php
/**
 * @method static Customer|Proxy createOne(array $attributes = [])
 * @method static Customer[]|Proxy[] createMany(int $number, $attributes = [])
 * @method static Customer|Proxy find($criteria)
 * @method static Customer|Proxy findOrCreate(array $attributes)
 * @method static Customer|Proxy first(string $sortedField = 'id')
 * @method static Customer|Proxy last(string $sortedField = 'id')
 * @method static Customer|Proxy random(array $attributes = [])
 * @method static Customer|Proxy randomOrCreate(array $attributes = [])
 * @method static Customer[]|Proxy[] all()
 * @method static Customer[]|Proxy[] findBy(array $attributes)
 * @method static Customer[]|Proxy[] randomSet(int $number, array $attributes = [])
 * @method static Customer[]|Proxy[] randomRange(int $min, int $max, array $attributes = [])
 * @method static CustomerRepository|RepositoryProxy repository()
 * @method Customer|Proxy create($attributes = [])
 */
class CustomerFactory extends ModelFactory
{}
  ```


Pull request description:
bug: PhpdocAlignFixer - fix static @method
Fixes #5867

Patch:
diff --git a/src/Fixer/Phpdoc/PhpdocAlignFixer.php b/src/Fixer/Phpdoc/PhpdocAlignFixer.php
index f72635d34..45bd6f968 100644
--- a/src/Fixer/Phpdoc/PhpdocAlignFixer.php
+++ b/src/Fixer/Phpdoc/PhpdocAlignFixer.php
@@ -113,7 +113,7 @@ public function configure(array $configuration): void
 
         // e.g. @method <hint> <signature>
         if ([] !== $tagsWithMethodSignatureToAlign) {
-            $types[] = '(?P<tag3>'.implode('|', $tagsWithMethodSignatureToAlign).')(\s+(?P<hint3>[^\s(]+)|)\s+(?P<signature>.+\))';
+            $types[] = '(?P<tag3>'.implode('|', $tagsWithMethodSignatureToAlign).')(\s+(?P<static>static))?(\s+(?P<hint3>[^\s(]+)|)\s+(?P<signature>.+\))';
         }
 
         // optional <desc>
@@ -255,6 +255,7 @@ private function fixDocBlock(DocBlock $docBlock): void
             }
 
             // compute the max length of the tag, hint and variables
+            $hasStatic = false;
             $tagMax = 0;
             $hintMax = 0;
             $varMax = 0;
@@ -264,6 +265,7 @@ private function fixDocBlock(DocBlock $docBlock): void
                     continue;
                 }
 
+                $hasStatic = $hasStatic || $item['static'];
                 $tagMax = max($tagMax, \strlen($item['tag']));
                 $hintMax = max($hintMax, \strlen($item['hint']));
                 $varMax = max($varMax, \strlen($item['var']));
@@ -286,6 +288,10 @@ private function fixDocBlock(DocBlock $docBlock): void
                         $extraIndent = 3;
                     }
 
+                    if ($hasStatic) {
+                        $extraIndent += 7; // \strlen('static ');
+                    }
+
                     $line =
                         $item['indent']
                         .' *  '
@@ -307,8 +313,24 @@ private function fixDocBlock(DocBlock $docBlock): void
                     $item['indent']
                     .' * @'
                     .$item['tag']
-                    .$this->getIndent(
-                        $tagMax - \strlen($item['tag']) + 1,
+                ;
+
+                if ($hasStatic) {
+                    $line .=
+                        $this->getIndent(
+                            $tagMax - \strlen($item['tag']) + 1,
+                            $item['static'] ? 1 : 0
+                        )
+                        .($item['static'] ?: $this->getIndent(6 /* \strlen('static') */, 0))
+                    ;
+                    $hintVerticalAlignIndent = 1;
+                } else {
+                    $hintVerticalAlignIndent = $tagMax - \strlen($item['tag']) + 1;
+                }
+
+                $line .=
+                    $this->getIndent(
+                        $hintVerticalAlignIndent,
                         $item['hint'] ? 1 : 0
                     )
                     .$item['hint']
@@ -351,12 +373,23 @@ private function getMatches(string $line, bool $matchCommentOnly = false): ?arra
                 $matches['tag'] = $matches['tag3'];
                 $matches['hint'] = $matches['hint3'];
                 $matches['var'] = $matches['signature'];
+
+                // Since static can be both a return type declaration & a keyword that defines static methods
+                // we assume it's a type declaration when only one value is present
+                if ('' === $matches['hint'] && '' !== $matches['static']) {
+                    $matches['hint'] = $matches['static'];
+                    $matches['static'] = '';
+                }
             }
 
             if (isset($matches['hint'])) {
                 $matches['hint'] = trim($matches['hint']);
             }
 
+            if (!isset($matches['static'])) {
+                $matches['static'] = '';
+            }
+
             return $matches;
         }
 
@@ -364,6 +397,7 @@ private function getMatches(string $line, bool $matchCommentOnly = false): ?arra
             $matches['tag'] = null;
             $matches['var'] = '';
             $matches['hint'] = '';
+            $matches['static'] = '';
 
             return $matches;
         }
@@ -403,6 +437,7 @@ private function getLeftAlignedDescriptionIndent(array $items, int $index): int
 
         // Indent according to existing values:
         return
+            $this->getSentenceIndent($item['static']) +
             $this->getSentenceIndent($item['tag']) +
             $this->getSentenceIndent($item['hint']) +
             $this->getSentenceIndent($item['var']);


Test patch:
diff --git a/tests/Fixer/Phpdoc/PhpdocAlignFixerTest.php b/tests/Fixer/Phpdoc/PhpdocAlignFixerTest.php
index 03730c1ee..130007df4 100644
--- a/tests/Fixer/Phpdoc/PhpdocAlignFixerTest.php
+++ b/tests/Fixer/Phpdoc/PhpdocAlignFixerTest.php
@@ -1103,6 +1103,138 @@ public function testAlignsMethodsWithoutReturnType(): void
         $this->doTest($expected, $input);
     }
 
+    public function testAlignsStaticAndNonStaticMethods(): void
+    {
+        $this->fixer->configure(['tags' => ['method', 'property']]);
+
+        $expected = <<<'EOF'
+<?php
+    /**
+     * @property        string      $foo             Desc1
+     * @property        int         $bar             Desc2
+     * @method                      foo(string $foo) DescriptionFoo
+     * @method          static      bar(string $foo) DescriptionBar
+     * @method          string|null baz(bool $baz)   DescriptionBaz
+     * @method   static int|false   qux(float $qux)  DescriptionQux
+     * @method   static static      quux(int $quux)  DescriptionQuux
+     * @method   static $this       quuz(bool $quuz) DescriptionQuuz
+     */
+EOF;
+
+        $input = <<<'EOF'
+<?php
+    /**
+     * @property    string   $foo     Desc1
+     * @property  int   $bar   Desc2
+     * @method     foo(string $foo)    DescriptionFoo
+     * @method  static     bar(string $foo) DescriptionBar
+     * @method    string|null    baz(bool $baz)  DescriptionBaz
+     * @method static     int|false qux(float $qux) DescriptionQux
+     * @method static   static    quux(int $quux) DescriptionQuux
+     * @method static  $this     quuz(bool $quuz) DescriptionQuuz
+     */
+EOF;
+
+        $this->doTest($expected, $input);
+    }
+
+    public function testAlignsStaticAndNonStaticMethodsLeftAlign(): void
+    {
+        $this->fixer->configure(['tags' => ['method', 'property'], 'align' => PhpdocAlignFixer::ALIGN_LEFT]);
+
+        $expected = <<<'EOF'
+<?php
+    /**
+     * @property string $foo Desc1
+     * @property int $bar Desc2
+     * @method foo(string $foo) DescriptionFoo
+     * @method static bar(string $foo) DescriptionBar
+     * @method string|null baz(bool $baz) DescriptionBaz
+     * @method static int|false qux(float $qux) DescriptionQux
+     * @method static static quux(int $quux) DescriptionQuux
+     * @method static $this quuz(bool $quuz) DescriptionQuuz
+     */
+EOF;
+
+        $input = <<<'EOF'
+<?php
+    /**
+     * @property    string   $foo     Desc1
+     * @property  int   $bar   Desc2
+     * @method     foo(string $foo)    DescriptionFoo
+     * @method  static     bar(string $foo) DescriptionBar
+     * @method    string|null    baz(bool $baz)  DescriptionBaz
+     * @method static     int|false qux(float $qux) DescriptionQux
+     * @method static   static    quux(int $quux) DescriptionQuux
+     * @method static  $this     quuz(bool $quuz) DescriptionQuuz
+     */
+EOF;
+
+        $this->doTest($expected, $input);
+    }
+
+    public function testAlignsReturnStatic(): void
+    {
+        $this->fixer->configure(['tags' => ['param', 'return', 'throws']]);
+
+        $expected = <<<'EOF'
+<?php
+    /**
+     * @param  string    $foobar Desc1
+     * @param  int       &$baz   Desc2
+     * @param  ?Qux      $qux    Desc3
+     * @param  int|float $quux   Desc4
+     * @return static    DescriptionReturn
+     * @throws Exception DescriptionException
+     */
+EOF;
+
+        $input = <<<'EOF'
+<?php
+    /**
+     * @param    string   $foobar     Desc1
+     * @param  int   &$baz   Desc2
+     * @param ?Qux       $qux   Desc3
+     * @param    int|float $quux   Desc4
+     * @return  static     DescriptionReturn
+     * @throws   Exception       DescriptionException
+     */
+EOF;
+
+        $this->doTest($expected, $input);
+    }
+
+    public function testAlignsReturnStaticLeftAlign(): void
+    {
+        $this->fixer->configure(['tags' => ['param', 'return', 'throws'], 'align' => PhpdocAlignFixer::ALIGN_LEFT]);
+
+        $expected = <<<'EOF'
+<?php
+    /**
+     * @param string $foobar Desc1
+     * @param int &$baz Desc2
+     * @param ?Qux $qux Desc3
+     * @param int|float $quux Desc4
+     * @return static DescriptionReturn
+     * @throws Exception DescriptionException
+     */
+EOF;
+
+        $input = <<<'EOF'
+<?php
+    /**
+     * @param    string   $foobar     Desc1
+     * @param  int   &$baz   Desc2
+     * @param ?Qux       $qux   Desc3
+     * @param    int|float $quux   Desc4
+     * @return  static     DescriptionReturn
+     * @throws   Exception       DescriptionException
+     */
+EOF;
+
+        $this->doTest($expected, $input);
+    }
+
     public function testDoesNotAlignWithEmptyConfig(): void
     {
         $this->fixer->configure(['tags' => []]);