diff --git a/src/DocBlock/TypeExpression.php b/src/DocBlock/TypeExpression.php index 9235735d4f8..72726a5497b 100644 --- a/src/DocBlock/TypeExpression.php +++ b/src/DocBlock/TypeExpression.php @@ -32,14 +32,16 @@ final class TypeExpression public const REGEX_TYPES = ' (? # alternation of several types separated by `|` (? # single type - \?? # optionally nullable + (?\??) (?: (? - array\h*\{ - (? - \h*[^?:\h]+\h*\??\h*:\h*(?&types) + (?array\h*\{) + (? + (? + \h*[^?:\h]+\h*\??\h*:\h*(?&types) + ) + (?:\h*,(?&object_like_array_key))* ) - (?:\h*,(?&object_like_array_key))* \h*\} ) | @@ -51,7 +53,7 @@ final class TypeExpression \h*,\h* (?&types) )* - ) + )? \h*\) (?: \h*\:\h* @@ -256,10 +258,11 @@ private function parse(): void return; } + $index = '' !== $matches['nullable'] ? 1 : 0; + if ($matches['type'] !== $matches['types']) { $this->isUnionType = true; - $index = 0; while (true) { $innerType = $matches['type']; @@ -291,7 +294,7 @@ private function parse(): void if ('' !== ($matches['generic'] ?? '')) { $this->parseCommaSeparatedInnerTypes( - \strlen($matches['generic_start']), + $index + \strlen($matches['generic_start']), $matches['generic_types'] ); @@ -300,7 +303,7 @@ private function parse(): void if ('' !== ($matches['callable'] ?? '')) { $this->parseCommaSeparatedInnerTypes( - \strlen($matches['callable_start']), + $index + \strlen($matches['callable_start']), $matches['callable_arguments'] ); @@ -308,9 +311,18 @@ private function parse(): void if (null !== $return) { $this->innerTypeExpressions[] = [ 'start_index' => \strlen($this->value) - \strlen($matches['callable_return']), - 'expression' => new self($matches['callable_return'], $this->namespace, $this->namespaceUses), + 'expression' => $this->inner($matches['callable_return']), ]; } + + return; + } + + if ('' !== ($matches['object_like_array'] ?? '')) { + $this->parseObjectLikeArrayKeys( + $index + \strlen($matches['object_like_array_start']), + $matches['object_like_array_keys'] + ); } } @@ -325,7 +337,7 @@ private function parseCommaSeparatedInnerTypes(int $startIndex, string $value): $this->innerTypeExpressions[] = [ 'start_index' => $startIndex, - 'expression' => new self($matches['types'], $this->namespace, $this->namespaceUses), + 'expression' => $this->inner($matches['types']), ]; $newValue = Preg::replace( @@ -339,6 +351,31 @@ private function parseCommaSeparatedInnerTypes(int $startIndex, string $value): } } + private function parseObjectLikeArrayKeys(int $startIndex, string $value): void + { + while ('' !== $value) { + Preg::match( + '{(?<_start>^.+?:\h*)'.self::REGEX_TYPES.'\h*(?:,|$)}x', + $value, + $matches + ); + + $this->innerTypeExpressions[] = [ + 'start_index' => $startIndex + \strlen($matches['_start']), + 'expression' => $this->inner($matches['types']), + ]; + + $newValue = Preg::replace( + '/^.+?:\h*'.preg_quote($matches['types'], '/').'(\h*\,\h*)?/', + '', + $value + ); + + $startIndex += \strlen($value) - \strlen($newValue); + $value = $newValue; + } + } + private function inner(string $value): self { return new self($value, $this->namespace, $this->namespaceUses); diff --git a/tests/DocBlock/TypeExpressionTest.php b/tests/DocBlock/TypeExpressionTest.php index f947c128f6f..480022745ee 100644 --- a/tests/DocBlock/TypeExpressionTest.php +++ b/tests/DocBlock/TypeExpressionTest.php @@ -179,4 +179,90 @@ public function provideAllowsNullCases(): \Generator yield ['bool', false]; yield ['string', false]; } + + /** + * @dataProvider provideSortUnionTypesCases + */ + public function testSortUnionTypes(string $typesExpression, string $expectResult): void + { + $expression = new TypeExpression($typesExpression, null, []); + + $expression->sortUnionTypes(static function (TypeExpression $a, TypeExpression $b): int { + return strcasecmp($a->toString(), $b->toString()); + }); + + static::assertSame($expectResult, $expression->toString()); + } + + public function provideSortUnionTypesCases(): iterable + { + yield 'not a union type' => [ + 'int', + 'int', + ]; + yield 'simple' => [ + 'int|bool', + 'bool|int', + ]; + yield 'simple in generic' => [ + 'array', + 'array', + ]; + yield 'generic with multiple types' => [ + 'array', + 'array', + ]; + yield 'simple in array shape with int key' => [ + 'array{0: int|bool}', + 'array{0: bool|int}', + ]; + yield 'simple in array shape with string key' => [ + 'array{"foo": int|bool}', + 'array{"foo": bool|int}', + ]; + yield 'simple in array shape with multiple keys' => [ + 'array{0: int|bool, "foo": int|bool}', + 'array{0: bool|int, "foo": bool|int}', + ]; + yield 'simple in callable argument' => [ + 'callable(int|bool)', + 'callable(bool|int)', + ]; + yield 'callable with multiple arguments' => [ + 'callable(int|bool, null|array)', + 'callable(bool|int, array|null)', + ]; + yield 'simple in callable return type' => [ + 'callable(): string|float', + 'callable(): float|string', + ]; + yield 'simple in closure argument' => [ + 'Closure(int|bool)', + 'Closure(bool|int)', + ]; + yield 'closure with multiple arguments' => [ + 'Closure(int|bool, null|array)', + 'Closure(bool|int, array|null)', + ]; + yield 'simple in closure return type' => [ + 'Closure(): string|float', + 'Closure(): float|string', + ]; + yield 'with multiple nesting levels' => [ + 'array{0: Foo|Bar): Foo|Bar>}', + 'array{0: Bar|float|string): Bar|Foo>|Foo}', + ]; + yield 'nullable generic' => [ + '?array', + '?array', + ]; + yield 'nullable callable' => [ + '?callable(Foo|Bar): Foo|Bar', + '?callable(Bar|Foo): Bar|Foo', + ]; + yield 'nullable array shape' => [ + '?array{0: Foo|Bar}', + '?array{0: Bar|Foo}', + ]; + } } diff --git a/tests/Fixer/Phpdoc/PhpdocTypesOrderFixerTest.php b/tests/Fixer/Phpdoc/PhpdocTypesOrderFixerTest.php index 67546bee9aa..7ad3c3105c8 100644 --- a/tests/Fixer/Phpdoc/PhpdocTypesOrderFixerTest.php +++ b/tests/Fixer/Phpdoc/PhpdocTypesOrderFixerTest.php @@ -641,6 +641,9 @@ public function provideFixWithAlphaAlgorithmAndNullAlwaysLastCases(): array ' , DateTime): bool> */', ' , DateTime): bool> */', ], + [ + ' */', + ], ]; } }