Skip to content

Commit

Permalink
feat: phpDoc to property/return/param Fixer - allow fixing union type…
Browse files Browse the repository at this point in the history
…s on PHP >= 8 (#6359)

Co-authored-by: Greg Korba <greg@codito.dev>
  • Loading branch information
MortalFlesh and Wirone committed Nov 4, 2023
1 parent 204e6f4 commit 3c3ddcb
Show file tree
Hide file tree
Showing 8 changed files with 379 additions and 104 deletions.
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()) {
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

0 comments on commit 3c3ddcb

Please sign in to comment.