Browse Source

feat: Add support for callable template in PHPDoc parser (#7084)

Michael Voříšek 1 year ago
parent
commit
54e8f39bcb

+ 86 - 4
src/DocBlock/TypeExpression.php

@@ -46,6 +46,11 @@ final class TypeExpression
         )*+
     )';
 
+    /**
+     * Based on:
+     * - https://github.com/phpstan/phpdoc-parser/blob/1.26.0/doc/grammars/type.abnf fuzzing grammar
+     * - and https://github.com/phpstan/phpdoc-parser/blob/1.26.0/src/Parser/PhpDocParser.php parser impl.
+     */
     private const REGEX_TYPE = '(?<type>(?x) # single type
             (?<nullable>\??\h*)
             (?:
@@ -65,8 +70,33 @@ final class TypeExpression
                     \h*\}
                 )
                 |
-                (?<callable> # callable syntax, e.g. `callable(string, int...): bool`
-                    (?<callable_start>(?&name)\h*\(\h*)
+                (?<callable> # callable syntax, e.g. `callable(string, int...): bool`, `\Closure<T>(T, int): T`
+                    (?<callable_name>(?&name))
+                    (?<callable_template>
+                        (?<callable_template_start>\h*<\h*)
+                        (?<callable_template_inners>
+                            (?<callable_template_inner>
+                                (?<callable_template_inner_name>
+                                    (?&identifier)
+                                )
+                                (?<callable_template_inner_b> # template bound
+                                    \h+(?i)(?<callable_template_inner_b_kw>of|as)(?-i)\h+
+                                    (?<callable_template_inner_b_types>(?&types_inner))
+                                |)
+                                (?<callable_template_inner_d> # template default
+                                    \h*=\h*
+                                    (?<callable_template_inner_d_types>(?&types_inner))
+                                |)
+                            )
+                            (?:
+                                \h*,\h*
+                                (?&callable_template_inner)
+                            )*+
+                        )
+                        \h*>
+                        (?=\h*\()
+                    |)
+                    (?<callable_start>\h*\(\h*)
                     (?<callable_arguments>
                         (?<callable_argument>
                             (?<callable_argument_type>(?&types_inner))
@@ -377,8 +407,16 @@ final class TypeExpression
                 $matches['generic_types'][0]
             );
         } elseif ('' !== ($matches['callable'][0] ?? '') && $matches['callable'][1] === $nullableLength) {
+            $this->parseCallableTemplateInnerTypes(
+                $index + \strlen($matches['callable_name'][0])
+                    + \strlen($matches['callable_template_start'][0]),
+                $matches['callable_template_inners'][0]
+            );
+
             $this->parseCallableArgumentTypes(
-                $index + \strlen($matches['callable_start'][0]),
+                $index + \strlen($matches['callable_name'][0])
+                    + \strlen($matches['callable_template'][0])
+                    + \strlen($matches['callable_start'][0]),
                 $matches['callable_arguments'][0]
             );
 
@@ -454,6 +492,49 @@ final class TypeExpression
         }
     }
 
+    private function parseCallableTemplateInnerTypes(int $startIndex, string $value): void
+    {
+        $index = 0;
+        while (\strlen($value) !== $index) {
+            Preg::match(
+                '{\G(?:(?=1)0'.self::REGEX_TYPES.'|(?<_callable_template_inner>(?&callable_template_inner))(?:\h*,\h*|$))}',
+                $value,
+                $prematches,
+                0,
+                $index
+            );
+            $consumedValue = $prematches['_callable_template_inner'];
+            $consumedValueLength = \strlen($consumedValue);
+            $consumedCommaLength = \strlen($prematches[0]) - $consumedValueLength;
+
+            $addedPrefix = 'Closure<';
+            Preg::match(
+                '{^'.self::REGEX_TYPES.'$}',
+                $addedPrefix.$consumedValue.'>(): void',
+                $matches,
+                PREG_OFFSET_CAPTURE
+            );
+
+            if ('' !== $matches['callable_template_inner_b'][0]) {
+                $this->innerTypeExpressions[] = [
+                    'start_index' => $startIndex + $index + $matches['callable_template_inner_b_types'][1]
+                        - \strlen($addedPrefix),
+                    'expression' => $this->inner($matches['callable_template_inner_b_types'][0]),
+                ];
+            }
+
+            if ('' !== $matches['callable_template_inner_d'][0]) {
+                $this->innerTypeExpressions[] = [
+                    'start_index' => $startIndex + $index + $matches['callable_template_inner_d_types'][1]
+                        - \strlen($addedPrefix),
+                    'expression' => $this->inner($matches['callable_template_inner_d_types'][0]),
+                ];
+            }
+
+            $index += $consumedValueLength + $consumedCommaLength;
+        }
+    }
+
     private function parseCallableArgumentTypes(int $startIndex, string $value): void
     {
         $index = 0;
@@ -510,7 +591,8 @@ final class TypeExpression
             );
 
             $this->innerTypeExpressions[] = [
-                'start_index' => $startIndex + $index + $matches['array_shape_inner_value'][1] - \strlen($addedPrefix),
+                'start_index' => $startIndex + $index + $matches['array_shape_inner_value'][1]
+                    - \strlen($addedPrefix),
                 'expression' => $this->inner($matches['array_shape_inner_value'][0]),
             ];
 

+ 37 - 0
tests/DocBlock/TypeExpressionTest.php

@@ -209,6 +209,24 @@ final class TypeExpressionTest extends TestCase
 
         yield ['\\Closure(float|int): (bool|int)'];
 
+        yield ['Closure<T>(): T'];
+
+        yield ['Closure<Tx, Ty>(): array{x: Tx, y: Ty}'];
+
+        yield ['array  <  int   , callable  (  string  )  :   bool  >'];
+
+        yield ['Closure<T of Foo>(T): T'];
+
+        yield ['Closure< T1 of Foo, T2 AS Foo >(T1): T2'];
+
+        yield ['Closure<T = Foo>(T): T'];
+
+        yield ['Closure<T1=int, T2 of Foo = Foo2>(T1): T2'];
+
+        yield ['Closure<T of string = \'\'>(T): T'];
+
+        yield ['Closure<Closure_can_be_regular_class>'];
+
         yield ['Closure(int $a)'];
 
         yield ['Closure(int $a): bool'];
@@ -376,6 +394,10 @@ final class TypeExpressionTest extends TestCase
         yield ['\' unclosed string \\\''];
 
         yield 'generic with no arguments' => ['f<>'];
+
+        yield 'generic Closure with no arguments' => ['Closure<>(): void'];
+
+        yield 'generic Closure with non-identifier template argument' => ['Closure<A|B>(): void'];
     }
 
     public function testHugeType(): void
@@ -814,6 +836,21 @@ final class TypeExpressionTest extends TestCase
             'array<string, array{ \Closure(mixed, string, $this): (float|int)|string }|string>|false',
         ];
 
+        yield 'generic Closure' => [
+            'Closure<B, A>(y|x, U<p|o>|B|A): (Y|B|X)',
+            'Closure<B, A>(x|y, A|B|U<o|p>): (B|X|Y)',
+        ];
+
+        yield 'generic Closure with bound template' => [
+            'Closure<B of J|I, C, A of V|U, D of object>(B|A): array{B, A, B, C, D}',
+            'Closure<B of I|J, C, A of U|V, D of object>(A|B): array{B, A, B, C, D}',
+        ];
+
+        yield 'generic Closure with template with default' => [
+            'Closure<T = B&A>(T): void',
+            'Closure<T = A&B>(T): void',
+        ];
+
         yield 'nullable generic' => [
             '?array<Foo|Bar>',
             '?array<Bar|Foo>',

+ 15 - 0
tests/Fixer/Phpdoc/PhpdocTypesOrderFixerTest.php

@@ -552,6 +552,21 @@ final class PhpdocTypesOrderFixerTest extends AbstractFixerTestCase
             '<?php /** @param A|((B&C)|D) */',
             '<?php /** @param (D|(C&B))|A */',
         ];
+
+        yield [
+            '<?php /** @var Closure<T>(T): T|null|string */',
+            '<?php /** @var string|Closure<T>(T): T|null */',
+        ];
+
+        yield [
+            '<?php /** @var \Closure<T of Model, T2, T3>(A|T, T3, T2): (T|T2)|null|string */',
+            '<?php /** @var string|\Closure<T of Model, T2, T3>(T|A, T3, T2): (T2|T)|null */',
+        ];
+
+        yield [
+            '<?php /** @var Closure<Closure_can_be_regular_class>|null|string */',
+            '<?php /** @var string|Closure<Closure_can_be_regular_class>|null */',
+        ];
     }
 
     /**