Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: phpDoc to property/return/param Fixer - allow fixing union types on PHP >= 8 #6359

Merged
118 changes: 87 additions & 31 deletions src/AbstractPhpdocToTypeDeclarationFixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ abstract class AbstractPhpdocToTypeDeclarationFixer extends AbstractFixer implem
'iterable' => 7_01_00,
'object' => 7_02_00,
'mixed' => 8_00_00,
'never' => 8_01_00,
];

/**
Expand Down Expand Up @@ -126,54 +127,49 @@ protected function getAnnotationsFromDocComment(string $name, Tokens $tokens, in
*/
protected function createTypeDeclarationTokens(string $type, bool $isNullable): array
{
static $specialTypes = [
'array' => [CT::T_ARRAY_TYPEHINT, 'array'],
'callable' => [T_CALLABLE, 'callable'],
'static' => [T_STATIC, 'static'],
];

$newTokens = [];

if (true === $isNullable && 'mixed' !== $type) {
$newTokens[] = new Token([CT::T_NULLABLE_TYPE, '?']);
}

if (isset($specialTypes[$type])) {
$newTokens[] = new Token($specialTypes[$type]);
} else {
$typeUnqualified = ltrim($type, '\\');

if (isset($this->scalarTypes[$typeUnqualified]) || isset($this->versionSpecificTypes[$typeUnqualified])) {
// 'scalar's, 'void', 'iterable' and 'object' must be unqualified
$newTokens[] = new Token([T_STRING, $typeUnqualified]);
} else {
foreach (explode('\\', $type) as $nsIndex => $value) {
if (0 === $nsIndex && '' === $value) {
continue;
}

if (0 < $nsIndex) {
$newTokens[] = new Token([T_NS_SEPARATOR, '\\']);
}
$newTokens = array_merge(
$newTokens,
$this->createTokensFromRawType($type)->toArray()
);

$newTokens[] = new Token([T_STRING, $value]);
// 'scalar's, 'void', 'iterable' and 'object' must be unqualified
foreach ($newTokens as $i => $token) {
if ($token->isGivenKind(T_STRING)) {
$typeUnqualified = $token->getContent();

if (
(isset($this->scalarTypes[$typeUnqualified]) || isset($this->versionSpecificTypes[$typeUnqualified]))
&& isset($newTokens[$i - 1])
&& '\\' === $newTokens[$i - 1]->getContent()
) {
unset($newTokens[$i - 1]);
}
}
}

return $newTokens;
return array_values($newTokens);
}

/**
* Each fixer inheriting from this class must define a way of creating token collection representing type
* gathered from phpDoc, e.g. `Foo|Bar` should be transformed into 3 tokens (`Foo`, `|` and `Bar`).
* This can't be standardised, because some types may be allowed in one place, and invalid in others.
*
* @param string $type Type determined (and simplified) from phpDoc
*/
abstract protected function createTokensFromRawType(string $type): Tokens;

/**
* @return null|array{string, bool}
*/
protected function getCommonTypeFromAnnotation(Annotation $annotation, bool $isReturnType): ?array
protected function getCommonTypeInfo(TypeExpression $typesExpression, bool $isReturnType): ?array
{
$typesExpression = $annotation->getTypeExpression();
if (null === $typesExpression) {
return null;
}

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

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

protected function getUnionTypes(TypeExpression $typesExpression, bool $isReturnType): ?string
{
if (\PHP_VERSION_ID < 8_00_00) {
return null;
}

if (!$typesExpression->isUnionType() || '|' !== $typesExpression->getTypesGlue()) {
Wirone marked this conversation as resolved.
Show resolved Hide resolved
return null;
}

$types = $typesExpression->getTypes();
$isNullable = $typesExpression->allowsNull();
$unionTypes = [];
$containsOtherThanIterableType = false;
$containsOtherThanEmptyType = false;

foreach ($types as $type) {
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
38 changes: 35 additions & 3 deletions src/Fixer/FunctionNotation/PhpdocToParamTypeFixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@
*/
final class PhpdocToParamTypeFixer extends AbstractPhpdocToTypeDeclarationFixer
{
private const TYPE_CHECK_TEMPLATE = '<?php function f(%s $x) {}';

/**
* @var array{int, string}[]
*/
Expand Down Expand Up @@ -116,13 +118,33 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
}

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

if (null === $typesExpression) {
continue;
}

$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 All @@ -141,7 +163,7 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
continue;
}

if (!$this->isValidSyntax(sprintf('<?php function f(%s $x) {}', $paramType))) {
if (!$this->isValidSyntax(sprintf(self::TYPE_CHECK_TEMPLATE, $paramType))) {
continue;
}

Expand All @@ -153,6 +175,16 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
}
}

protected function createTokensFromRawType(string $type): Tokens
{
$typeTokens = Tokens::fromCode(sprintf(self::TYPE_CHECK_TEMPLATE, $type));
$typeTokens->clearRange(0, 4);
$typeTokens->clearRange(\count($typeTokens) - 6, \count($typeTokens) - 1);
$typeTokens->clearEmptyTokens();

return $typeTokens;
}

private function findCorrectVariable(Tokens $tokens, int $startIndex, Annotation $paramTypeAnnotation): ?int
{
$endIndex = $tokens->findBlockEnd(Tokens::BLOCK_TYPE_PARENTHESIS_BRACE, $startIndex);
Expand Down
41 changes: 37 additions & 4 deletions src/Fixer/FunctionNotation/PhpdocToPropertyTypeFixer.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@

final class PhpdocToPropertyTypeFixer extends AbstractPhpdocToTypeDeclarationFixer
{
private const TYPE_CHECK_TEMPLATE = '<?php class A { private %s $b; }';

/**
* @var array<string, true>
*/
Expand Down Expand Up @@ -94,6 +96,16 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
}
}

protected function createTokensFromRawType(string $type): Tokens
{
$typeTokens = Tokens::fromCode(sprintf(self::TYPE_CHECK_TEMPLATE, $type));
$typeTokens->clearRange(0, 8);
$typeTokens->clearRange(\count($typeTokens) - 5, \count($typeTokens) - 1);
$typeTokens->clearEmptyTokens();

return $typeTokens;
}

private function fixClass(Tokens $tokens, int $index): void
{
$index = $tokens->getNextTokenOfKind($index, ['{']);
Expand Down Expand Up @@ -136,6 +148,10 @@ private function fixClass(Tokens $tokens, int $index): void
continue;
}

if (!$this->isValidSyntax(sprintf(self::TYPE_CHECK_TEMPLATE, $propertyType))) {
continue;
}

$newTokens = array_merge(
$this->createTypeDeclarationTokens($propertyType, $isNullable),
[new Token([T_WHITESPACE, ' '])]
Expand Down Expand Up @@ -203,11 +219,28 @@ private function resolveApplicableType(array $propertyIndices, array $annotation
continue;
}

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

if (null === $typesExpression) {
continue;
}

$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 (!isset($propertyTypes[$propertyName])) {
$propertyTypes[$propertyName] = [];
} elseif ($typeInfo !== $propertyTypes[$propertyName]) {
if (\array_key_exists($propertyName, $propertyTypes) && $typeInfo !== $propertyTypes[$propertyName]) {
return null;
}

Expand Down
46 changes: 41 additions & 5 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 All @@ -29,6 +30,8 @@
*/
final class PhpdocToReturnTypeFixer extends AbstractPhpdocToTypeDeclarationFixer
{
private const TYPE_CHECK_TEMPLATE = '<?php function f(): %s {}';

/**
* @var array<int, array<int, int|string>>
*/
Expand Down Expand Up @@ -135,26 +138,49 @@ 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;
}

/** @var Annotation $returnTypeAnnotation */
$returnTypeAnnotation = current($returnTypeAnnotations);

$typesExpression = $returnTypeAnnotation->getTypeExpression();

if (null === $typesExpression) {
continue;
}

$typeInfo = $this->getCommonTypeFromAnnotation(current($returnTypeAnnotation), true);
$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, ['{', ';']);

if ($this->hasReturnTypeHint($tokens, $startIndex)) {
continue;
}

if (!$this->isValidSyntax(sprintf('<?php function f():%s {}', $returnType))) {
if (!$this->isValidSyntax(sprintf(self::TYPE_CHECK_TEMPLATE, $returnType))) {
continue;
}

Expand All @@ -173,6 +199,16 @@ protected function applyFix(\SplFileInfo $file, Tokens $tokens): void
}
}

protected function createTokensFromRawType(string $type): Tokens
{
$typeTokens = Tokens::fromCode(sprintf(self::TYPE_CHECK_TEMPLATE, $type));
$typeTokens->clearRange(0, 7);
$typeTokens->clearRange(\count($typeTokens) - 3, \count($typeTokens) - 1);
$typeTokens->clearEmptyTokens();

return $typeTokens;
}

/**
* Determine whether the function already has a return type hint.
*
Expand Down