Skip to content

Commit

Permalink
Refactor conversion of PHPDoc to type declarations
Browse files Browse the repository at this point in the history
  • Loading branch information
julienfalque committed Mar 21, 2020
1 parent f71cee9 commit 9065c73
Show file tree
Hide file tree
Showing 15 changed files with 1,107 additions and 528 deletions.
203 changes: 203 additions & 0 deletions src/AbstractPhpdocToTypeDeclarationFixer.php
@@ -0,0 +1,203 @@
<?php

/*
* This file is part of PHP CS Fixer.
*
* (c) Fabien Potencier <fabien@symfony.com>
* Dariusz Rumiński <dariusz.ruminski@gmail.com>
*
* This source file is subject to the MIT license that is bundled
* with this source code in the file LICENSE.
*/

namespace PhpCsFixer;

use PhpCsFixer\DocBlock\Annotation;
use PhpCsFixer\DocBlock\DocBlock;
use PhpCsFixer\Fixer\ConfigurationDefinitionFixerInterface;
use PhpCsFixer\FixerConfiguration\FixerConfigurationResolver;
use PhpCsFixer\FixerConfiguration\FixerOptionBuilder;
use PhpCsFixer\Tokenizer\Analyzer\NamespacesAnalyzer;
use PhpCsFixer\Tokenizer\Analyzer\NamespaceUsesAnalyzer;
use PhpCsFixer\Tokenizer\CT;
use PhpCsFixer\Tokenizer\Token;
use PhpCsFixer\Tokenizer\Tokens;

/**
* @internal
*/
abstract class AbstractPhpdocToTypeDeclarationFixer extends AbstractFixer implements ConfigurationDefinitionFixerInterface
{
/**
* @var string
*/
private $classRegex = '/^\\\\?[a-zA-Z_\\x7f-\\xff](?:\\\\?[a-zA-Z0-9_\\x7f-\\xff]+)*$/';

/**
* @var array<string, int>
*/
private $versionSpecificTypes = [
'void' => 70100,
'iterable' => 70100,
'object' => 70200,
];

/**
* @var array<string, bool>
*/
private $scalarTypes = [
'bool' => true,
'float' => true,
'int' => true,
'string' => true,
];

/**
* @var array<string, bool>
*/
private $skippedTypes = [
'mixed' => true,
'resource' => true,
'null' => true,
];

/**
* {@inheritdoc}
*/
public function isRisky()
{
return true;
}

/**
* {@inheritdoc}
*/
protected function createConfigurationDefinition()
{
return new FixerConfigurationResolver([
(new FixerOptionBuilder('scalar_types', 'Fix also scalar types; may have unexpected behaviour due to PHP bad type coercion system.'))
->setAllowedTypes(['bool'])
->setDefault(true)
->getOption(),
]);
}

/**
* Find all the annotations of given type in the function's PHPDoc comment.
*
* @param string $name
* @param int $index The index of the function token
*
* @return Annotation[]
*/
protected function findAnnotations($name, Tokens $tokens, $index)
{
do {
$index = $tokens->getPrevNonWhitespace($index);
} while ($tokens[$index]->isGivenKind([
T_COMMENT,
T_ABSTRACT,
T_FINAL,
T_PRIVATE,
T_PROTECTED,
T_PUBLIC,
T_STATIC,
]));

if (!$tokens[$index]->isGivenKind(T_DOC_COMMENT)) {
return [];
}

$namespacesAnalyzer = new NamespacesAnalyzer();
$namespace = $namespacesAnalyzer->getNamespaceAt($tokens, $index);

$namespaceUsesAnalyzer = new NamespaceUsesAnalyzer();
$namespaceUses = $namespaceUsesAnalyzer->getDeclarationsInNamespace($tokens, $namespace);

$doc = new DocBlock(
$tokens[$index]->getContent(),
$namespace,
$namespaceUses
);

return $doc->getAnnotationsOfType($name);
}

/**
* @param string $type
* @param bool $isNullable
*
* @return Token[]
*/
protected function createTypeDeclarationTokens($type, $isNullable)
{
static $specialTypes = [
'array' => [CT::T_ARRAY_TYPEHINT, 'array'],
'callable' => [T_CALLABLE, 'callable'],
];

$newTokens = [];

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

if (isset($specialTypes[$type])) {
$newTokens[] = new Token($specialTypes[$type]);
} else {
foreach (explode('\\', $type) as $nsIndex => $value) {
if (0 === $nsIndex && '' === $value) {
continue;
}

if (0 < $nsIndex) {
$newTokens[] = new Token([T_NS_SEPARATOR, '\\']);
}
$newTokens[] = new Token([T_STRING, $value]);
}
}

return $newTokens;
}

/**
* @return null|array
*/
protected function getCommonTypeFromAnnotation(Annotation $annotation)
{
$typesExpression = $annotation->getTypeExpression();

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

if (null === $commonType) {
return null;
}

if ($isNullable && (\PHP_VERSION_ID < 70100 || 'void' === $commonType)) {
return null;
}

if ('static' === $commonType) {
$commonType = 'self';
}

if (isset($this->skippedTypes[$commonType])) {
return null;
}

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

if (isset($this->scalarTypes[$commonType])) {
if (false === $this->configuration['scalar_types']) {
return null;
}
} elseif (1 !== Preg::match($this->classRegex, $commonType)) {
return null;
}

return [$commonType, $isNullable];
}
}
95 changes: 43 additions & 52 deletions src/DocBlock/Annotation.php
Expand Up @@ -13,6 +13,8 @@
namespace PhpCsFixer\DocBlock;

use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;

/**
* This represents an entire annotation from a docblock.
Expand All @@ -22,41 +24,6 @@
*/
class Annotation
{
/**
* Regex to match any types, shall be used with `x` modifier.
*
* @internal
*/
const REGEX_TYPES = '
# <simple> is any non-array, non-generic, non-alternated type, eg `int` or `\Foo`
# <array> is array of <simple>, eg `int[]` or `\Foo[]`
# <generic> is generic collection type, like `array<string, int>`, `Collection<Item>` and more complex like `Collection<int, \null|SubCollection<string>>`
# <type> is <simple>, <array> or <generic> type, like `int`, `bool[]` or `Collection<ItemKey, ItemVal>`
# <types> is one or more types alternated via `|`, like `int|bool[]|Collection<ItemKey, ItemVal>`
(?<types>
(?<type>
(?<array>
(?&simple)(\[\])*
)
|
(?<simple>
[@$?]?[\\\\\w]+
)
|
(?<generic>
(?&simple)
<
(?:(?&types),\s*)?(?:(?&types)|(?&generic))
>
)
)
(?:
\|
(?:(?&simple)|(?&array)|(?&generic))
)*
)
';

/**
* All the annotation tag names with types.
*
Expand Down Expand Up @@ -116,14 +83,28 @@ class Annotation
*/
private $types;

/**
* @var null|NamespaceAnalysis
*/
private $namespace;

/**
* @var NamespaceUseAnalysis[]
*/
private $namespaceUses;

/**
* Create a new line instance.
*
* @param Line[] $lines
* @param Line[] $lines
* @param null|NamespaceAnalysis $namespace
* @param NamespaceUseAnalysis[] $namespaceUses
*/
public function __construct(array $lines)
public function __construct(array $lines, $namespace = null, array $namespaceUses = [])
{
$this->lines = array_values($lines);
$this->namespace = $namespace;
$this->namespaceUses = $namespaceUses;

$keys = array_keys($lines);

Expand Down Expand Up @@ -185,6 +166,29 @@ public function getTag()
return $this->tag;
}

/**
* @return TypeExpression
*/
public function getTypeExpression()
{
return new TypeExpression($this->getTypesContent(), $this->namespace, $this->namespaceUses);
}

/**
* @return null|string
*/
public function getVariableName()
{
$type = preg_quote($this->getTypesContent(), '/');
$regex = "/@{$this->tag->getName()}\\s+{$type}\\s+(?<variable>\\$.+?)(?:[\\s*]|$)/";

if (Preg::match($regex, $this->lines[0]->getContent(), $matches)) {
return $matches['variable'];
}

return null;
}

/**
* Get the types associated with this annotation.
*
Expand All @@ -193,20 +197,7 @@ public function getTag()
public function getTypes()
{
if (null === $this->types) {
$this->types = [];

$content = $this->getTypesContent();

while ('' !== $content && false !== $content) {
Preg::match(
'{^'.self::REGEX_TYPES.'$}x',
$content,
$matches
);

$this->types[] = $matches['type'];
$content = substr($content, \strlen($matches['type']) + 1);
}
$this->types = $this->getTypeExpression()->getTypes();
}

return $this->types;
Expand Down Expand Up @@ -286,7 +277,7 @@ private function getTypesContent()
}

$matchingResult = Preg::match(
'{^(?:\s*\*|/\*\*)\s*@'.$name.'\s+'.self::REGEX_TYPES.'(?:\h.*)?$}sx',
'{^(?:\s*\*|/\*\*)\s*@'.$name.'\s+'.TypeExpression::REGEX_TYPES.'(?:\h.*)?$}sx',
$this->lines[0]->getContent(),
$matches
);
Expand Down
23 changes: 20 additions & 3 deletions src/DocBlock/DocBlock.php
Expand Up @@ -13,6 +13,8 @@
namespace PhpCsFixer\DocBlock;

use PhpCsFixer\Preg;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceAnalysis;
use PhpCsFixer\Tokenizer\Analyzer\Analysis\NamespaceUseAnalysis;

/**
* This class represents a docblock.
Expand All @@ -37,16 +39,31 @@ class DocBlock
*/
private $annotations;

/**
* @var null|NamespaceAnalysis
*/
private $namespace;

/**
* @var NamespaceUseAnalysis[]
*/
private $namespaceUses;

/**
* Create a new docblock instance.
*
* @param string $content
* @param string $content
* @param null|NamespaceAnalysis $namespace
* @param NamespaceUseAnalysis[] $namespaceUses
*/
public function __construct($content)
public function __construct($content, $namespace = null, array $namespaceUses = [])
{
foreach (Preg::split('/([^\n\r]+\R*)/', $content, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE) as $line) {
$this->lines[] = new Line($line);
}

$this->namespace = $namespace;
$this->namespaceUses = $namespaceUses;
}

/**
Expand Down Expand Up @@ -99,7 +116,7 @@ public function getAnnotations()
if ($this->lines[$index]->containsATag()) {
// get all the lines that make up the annotation
$lines = \array_slice($this->lines, $index, $this->findAnnotationLength($index), true);
$annotation = new Annotation($lines);
$annotation = new Annotation($lines, $this->namespace, $this->namespaceUses);
// move the index to the end of the annotation to avoid
// checking it again because we know the lines inside the
// current annotation cannot be part of another annotation
Expand Down

0 comments on commit 9065c73

Please sign in to comment.