Skip to content

Commit

Permalink
PhpdocTo Property/Return/Param Fixer - allow fixing union types on PH…
Browse files Browse the repository at this point in the history
…P >= 8
  • Loading branch information
MortalFlesh committed Aug 19, 2022
1 parent 1bb3d2f commit c2f5d68
Show file tree
Hide file tree
Showing 10 changed files with 305 additions and 19 deletions.
75 changes: 72 additions & 3 deletions src/AbstractPhpdocToTypeDeclarationFixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

use PhpCsFixer\DocBlock\Annotation;
use PhpCsFixer\DocBlock\DocBlock;
use PhpCsFixer\DocBlock\TypeExpression;
use PhpCsFixer\Fixer\ConfigurableFixerInterface;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolverInterface;
Expand All @@ -41,6 +42,7 @@ abstract class AbstractPhpdocToTypeDeclarationFixer extends AbstractFixer implem
'iterable' => 70100,
'object' => 70200,
'mixed' => 80000,
'union-types' => 80000,
];

/**
Expand Down Expand Up @@ -168,10 +170,8 @@ protected function createTypeDeclarationTokens(string $type, bool $isNullable):
return $newTokens;
}

protected function getCommonTypeFromAnnotation(Annotation $annotation, bool $isReturnType): ?array
protected function getCommonTypeInfo(TypeExpression $typesExpression, bool $isReturnType): ?array
{
$typesExpression = $annotation->getTypeExpression();

$commonType = $typesExpression->getCommonType();
$isNullable = $typesExpression->allowsNull();

Expand Down Expand Up @@ -206,6 +206,75 @@ protected function getCommonTypeFromAnnotation(Annotation $annotation, bool $isR
return [$commonType, $isNullable];
}

protected function getUnionTypes(TypeExpression $typesExpression, bool $isReturnType): ?string
{
if (\PHP_VERSION_ID < $this->versionSpecificTypes['union-types']) {
return null;
}

if (!$typesExpression->isUnionType()) {
return null;
}

$types = $typesExpression->getTypes();
$isNullable = $typesExpression->allowsNull();

if (\count($types) < 2) {
return null;
}

$unionTypes = [];
$containsOtherThanIterableType = false;
$containsOtherThanEmptyType = false;

foreach ($types as $type) {
if (empty($type)) {
return null;
}

if ('null' === $type) {
continue;
}

if ($this->isSkippedType($type)) {
return null;
}

if (isset($this->versionSpecificTypes[$type]) && \PHP_VERSION_ID < $this->versionSpecificTypes[$type]) {
return null;
}

$typeExpression = new TypeExpression($type, null, []);
$commonType = $typeExpression->getCommonType();

if (!$containsOtherThanIterableType && !\in_array($commonType, ['array', 'Traversable', 'iterable'], true)) {
$containsOtherThanIterableType = true;
}
if ($isReturnType && !$containsOtherThanEmptyType && !\in_array($commonType, ['null', 'void', 'never'], true)) {
$containsOtherThanEmptyType = true;
}

if (!$isNullable && $typesExpression->allowsNull()) {
$isNullable = true;
}

$unionTypes[] = $commonType;
}

if (!$containsOtherThanIterableType) {
return null;
}
if ($isReturnType && !$containsOtherThanEmptyType) {
return null;
}

if ($isNullable) {
$unionTypes[] = 'null';
}

return implode($typesExpression->getTypesGlue(), array_unique($unionTypes));
}

final protected function isValidSyntax(string $code): bool
{
if (!isset(self::$syntaxValidationCache[$code])) {
Expand Down
5 changes: 5 additions & 0 deletions src/DocBlock/TypeExpression.php
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,11 @@ public function __construct(string $value, ?NamespaceAnalysis $namespace, array
$this->parse();
}

public function isUnionType(): bool
{
return $this->isUnionType && '|' === $this->getTypesGlue();
}

public function toString(): string
{
return $this->value;
Expand Down
20 changes: 18 additions & 2 deletions src/Fixer/FunctionNotation/PhpdocToParamTypeFixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,13 +125,29 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
}

foreach ($this->getAnnotationsFromDocComment('param', $tokens, $docCommentIndex) as $paramTypeAnnotation) {
$typeInfo = $this->getCommonTypeFromAnnotation($paramTypeAnnotation, false);
$typesExpression = $paramTypeAnnotation->getTypeExpression();

$typeInfo = $this->getCommonTypeInfo($typesExpression, false);
$unionTypes = null;

if (null === $typeInfo) {
$unionTypes = $this->getUnionTypes($typesExpression, false);
}

if (null === $typeInfo && null === $unionTypes) {
continue;
}

[$paramType, $isNullable] = $typeInfo;
if (null !== $typeInfo) {
[$paramType, $isNullable] = $typeInfo;
} elseif (null !== $unionTypes) {
$paramType = $unionTypes;
$isNullable = false;
}

if (!isset($paramType, $isNullable)) {
continue;
}

$startIndex = $tokens->getNextTokenOfKind($index, ['(']);
$variableIndex = $this->findCorrectVariable($tokens, $startIndex, $paramTypeAnnotation);
Expand Down
21 changes: 17 additions & 4 deletions src/Fixer/FunctionNotation/PhpdocToPropertyTypeFixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -215,11 +215,24 @@ private function resolveApplicableType(array $propertyIndices, array $annotation
continue;
}

$typeInfo = $this->getCommonTypeFromAnnotation($annotation, false);
$typesExpression = $annotation->getTypeExpression();

if (!isset($propertyTypes[$propertyName])) {
$propertyTypes[$propertyName] = [];
} elseif ($typeInfo !== $propertyTypes[$propertyName]) {
$typeInfo = $this->getCommonTypeInfo($typesExpression, false);
$unionTypes = null;

if (null === $typeInfo) {
$unionTypes = $this->getUnionTypes($typesExpression, false);
}

if (null === $typeInfo && null === $unionTypes) {
continue;
}

if (null !== $unionTypes) {
$typeInfo = [$unionTypes, false];
}

if (\array_key_exists($propertyName, $propertyTypes) && $typeInfo !== $propertyTypes[$propertyName]) {
return null;
}

Expand Down
27 changes: 23 additions & 4 deletions src/Fixer/FunctionNotation/PhpdocToReturnTypeFixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
namespace PhpCsFixer\Fixer\FunctionNotation;

use PhpCsFixer\AbstractPhpdocToTypeDeclarationFixer;
use PhpCsFixer\DocBlock\Annotation;
use PhpCsFixer\FixerDefinition\CodeSample;
use PhpCsFixer\FixerDefinition\FixerDefinition;
use PhpCsFixer\FixerDefinition\FixerDefinitionInterface;
Expand Down Expand Up @@ -153,18 +154,36 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
continue;
}

$returnTypeAnnotation = $this->getAnnotationsFromDocComment('return', $tokens, $docCommentIndex);
if (1 !== \count($returnTypeAnnotation)) {
$returnTypeAnnotations = $this->getAnnotationsFromDocComment('return', $tokens, $docCommentIndex);
if (1 !== \count($returnTypeAnnotations)) {
continue;
}

$typeInfo = $this->getCommonTypeFromAnnotation(current($returnTypeAnnotation), true);
/** @var Annotation $returnTypeAnnotation */
$returnTypeAnnotation = current($returnTypeAnnotations);

$typesExpression = $returnTypeAnnotation->getTypeExpression();
$typeInfo = $this->getCommonTypeInfo($typesExpression, true);
$unionTypes = null;

if (null === $typeInfo) {
$unionTypes = $this->getUnionTypes($typesExpression, true);
}

if (null === $typeInfo && null === $unionTypes) {
continue;
}

[$returnType, $isNullable] = $typeInfo;
if (null !== $typeInfo) {
[$returnType, $isNullable] = $typeInfo;
} elseif (null !== $unionTypes) {
$returnType = $unionTypes;
$isNullable = false;
}

if (!isset($returnType, $isNullable)) {
continue;
}

$startIndex = $tokens->getNextTokenOfKind($index, ['{', ';']);

Expand Down
28 changes: 28 additions & 0 deletions tests/DocBlock/TypeExpressionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,34 @@ public static function provideGetTypesGlueCases(): iterable
yield ['&', 'Foo&Bar'];
}

/**
* @dataProvider provideUnionTypesCases
*/
public function testIsUnionType(bool $expectedIsUnionType, string $typesExpression): void
{
$expression = new TypeExpression($typesExpression, null, []);
static::assertSame($expectedIsUnionType, $expression->isUnionType());
}

public static function provideUnionTypesCases(): iterable
{
yield [false, 'string'];

yield [true, 'bool|string'];

yield [true, 'int|string|null'];

yield [true, 'int|?string'];

yield [true, 'int|null'];

yield [false, '?int'];

yield [true, 'Foo|Bar'];

yield [false, 'Foo&Bar'];
}

/**
* @param NamespaceUseAnalysis[] $namespaceUses
*
Expand Down
60 changes: 57 additions & 3 deletions tests/Fixer/FunctionNotation/PhpdocToParamTypeFixerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
*
* @internal
*
* @group phpdoc
* @covers \PhpCsFixer\Fixer\FunctionNotation\PhpdocToParamTypeFixer
*/
final class PhpdocToParamTypeFixerTest extends AbstractFixerTestCase
Expand Down Expand Up @@ -262,14 +263,63 @@ class Foo {
'null alone cannot be a param type' => [
'<?php /** @param $bar null */ function my_foo($bar) {}',
],
'skip mixed types' => [
'skip union types' => [
'<?php /** @param Foo|Bar $bar */ function my_foo($bar) {}',
null,
null,
[],
80000,
],
'skip mixed types including array' => [
'union types' => [
'<?php /** @param Foo|Bar $bar */ function my_foo(Foo|Bar $bar) {}',
'<?php /** @param Foo|Bar $bar */ function my_foo($bar) {}',
80000,
],
'skip union types including nullable' => [
'<?php /** @param string|?int $bar */ function my_foo($bar) {}',
null,
null,
[],
80000,
],
'union types including nullable' => [
'<?php /** @param string|?int $bar */ function my_foo(string|int|null $bar) {}',
'<?php /** @param string|?int $bar */ function my_foo($bar) {}',
80000,
],
'union types including generics' => [
'<?php /** @param array<string, int>|string $bar */ function my_foo(array|string $bar) {}',
'<?php /** @param array<string, int>|string $bar */ function my_foo($bar) {}',
80000,
],
'skip union types including array' => [
'<?php /** @param array|Foo $expected */ function testResolveIntersectionOfPaths($expected) {}',
null,
null,
[],
80000,
],
'fix union types including generics' => [
'<?php /** @param string|array<string, int> $bar */ function my_foo(string|array $bar) {}',
'<?php /** @param string|array<string, int> $bar */ function my_foo($bar) {}',
80000,
],
'union types including array' => [
'<?php /** @param array|Foo $expected */ function testResolveIntersectionOfPaths(array|Foo $expected) {}',
'<?php /** @param array|Foo $expected */ function testResolveIntersectionOfPaths($expected) {}',
80000,
],
'skip primitive or array types' => [
'skip primitive or array union types' => [
'<?php /** @param string|string[] $expected */ function testResolveIntersectionOfPaths($expected) {}',
null,
null,
[],
80000,
],
'primitive or array union types' => [
'<?php /** @param string|string[] $expected */ function testResolveIntersectionOfPaths(string|array $expected) {}',
'<?php /** @param string|string[] $expected */ function testResolveIntersectionOfPaths($expected) {}',
80000,
],
'array of types' => [
'<?php /** @param Foo[] $foo */ function my_foo(array $foo) {}',
Expand Down Expand Up @@ -363,6 +413,10 @@ class Foo {
'<?php /** @param array|\Traversable $foo */ function my_foo(iterable $foo) {}',
'<?php /** @param array|\Traversable $foo */ function my_foo($foo) {}',
],
'string array and iterable' => [
'<?php /** @param string[]|iterable $foo */ function my_foo(iterable $foo) {}',
'<?php /** @param string[]|iterable $foo */ function my_foo($foo) {}',
],
'array and traversable in a namespace' => [
'<?php
namespace App;
Expand Down

0 comments on commit c2f5d68

Please sign in to comment.