Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\GenericAnnotation;

/**
* @GenericAnnotation
* (
* title = "Next line",
* summary = "Some summary"
* )
*/
final class ArgumentsOnNextLine
{
}

/**
* @\Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\GenericAnnotation
* (title = "Fully qualified")
*/
final class FullyQualifiedArgumentsOnNextLine
{
}

?>
-----
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\GenericAnnotation;

#[GenericAnnotation(title: 'Next line', summary: 'Some summary')]
final class ArgumentsOnNextLine
{
}

#[\Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\GenericAnnotation(title: 'Fully qualified')]
final class FullyQualifiedArgumentsOnNextLine
{
}

?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\GenericAnnotation;

/**
* @GenericAnnotation
* (title = 'single quoted')
*/
final class SkipInvalidArgumentsOnNextLine
{
}

?>
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\FixturePhp81;

use Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Source\GenericAnnotation;

/**
* @GenericAnnotation
* (
* some={
* @GenericAnnotation(title="inner")
* }
* )
*/
final class SkipNestedAnnotationInArgumentsOnNextLine
{
}

?>
101 changes: 87 additions & 14 deletions src/BetterPhpDocParser/NodeDecorator/DoctrineAnnotationDecorator.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@
use PHPStan\PhpDocParser\Ast\PhpDoc\InvalidTagValueNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode;
use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode;
use PHPStan\PhpDocParser\Lexer\Lexer;
use PHPStan\PhpDocParser\Parser\PhpDocParser;
use PHPStan\Type\ObjectType;
use Rector\BetterPhpDocParser\Attributes\AttributeMirrorer;
use Rector\BetterPhpDocParser\Contract\PhpDocParser\PhpDocNodeDecoratorInterface;
Expand Down Expand Up @@ -52,6 +54,7 @@
public function __construct(
private ClassAnnotationMatcher $classAnnotationMatcher,
private TokenIteratorFactory $tokenIteratorFactory,
private PhpDocParser $phpDocParser,
private AttributeMirrorer $attributeMirrorer,
private ObjectTypeSpecifier $objectTypeSpecifier
) {
Expand Down Expand Up @@ -85,13 +88,33 @@ public function decorate(PhpDocNode $phpDocNode, Node $phpNode): void
continue;
}

if ($phpDocChildNode->value instanceof DoctrineTagValueNode) {
$doctrineTagValueNode = $phpDocChildNode->value;

// phpdoc-parser reads the arguments only when "(" follows the tag name directly
$argumentsOnNextLine = $this->resolveArgumentsOnNextLine($doctrineTagValueNode);
if ($argumentsOnNextLine !== null && $this->resolveAnnotationClass(
$phpDocChildNode->name,
$phpNode
) !== null) {
$doctrineTagValueNode = $this->parseDoctrineTagValue($phpDocChildNode->name, $argumentsOnNextLine);

// keep the tag as it is, so its content is not lost
if (! $doctrineTagValueNode instanceof DoctrineTagValueNode) {
// e.g. the arguments end after a nested annotation on the next line; keep these too
if (! $this->isClosedContent($argumentsOnNextLine)) {
$incompleteAnnotationContent = $argumentsOnNextLine;
}

continue;
}
}

if ($doctrineTagValueNode instanceof DoctrineTagValueNode) {
$fullyQualifiedAnnotationClass = $this->resolveDoctrineTagClass($phpDocChildNode->name, $phpNode);
if ($fullyQualifiedAnnotationClass === null) {
continue;
}

$doctrineTagValueNode = $phpDocChildNode->value;
$originalContent = '(' . implode(', ', $doctrineTagValueNode->annotation->arguments) . ')'
. $doctrineTagValueNode->description;

Expand All @@ -110,18 +133,8 @@ public function decorate(PhpDocNode $phpDocNode, Node $phpNode): void
continue;
}

// known doc tag to annotation class
$fullyQualifiedAnnotationClass = $this->classAnnotationMatcher->resolveTagFullyQualifiedName(
$phpDocChildNode->name,
$phpNode
);

// not an annotations class
if (! \str_contains($fullyQualifiedAnnotationClass, '\\') && ! in_array(
$fullyQualifiedAnnotationClass,
self::ALLOWED_SHORT_ANNOTATIONS,
true
)) {
$fullyQualifiedAnnotationClass = $this->resolveAnnotationClass($phpDocChildNode->name, $phpNode);
if ($fullyQualifiedAnnotationClass === null) {
continue;
}

Expand All @@ -134,6 +147,66 @@ public function decorate(PhpDocNode $phpDocNode, Node $phpNode): void
}
}

/**
* Doctrine skips the whitespace and "*" between the tag name and "(",
* e.g. "@Action" with "(id="value")" on the next line
*/
private function resolveArgumentsOnNextLine(PhpDocTagValueNode $phpDocTagValueNode): ?string
{
if ($phpDocTagValueNode instanceof GenericTagValueNode) {
$content = $phpDocTagValueNode->value;
} elseif ($phpDocTagValueNode instanceof DoctrineTagValueNode && $phpDocTagValueNode->annotation->arguments === []) {
$content = $phpDocTagValueNode->description;
} else {
return null;
}

$content = ltrim($content);
if (! str_starts_with($content, '(')) {
return null;
}

return $content;
}

private function parseDoctrineTagValue(string $tagName, string $arguments): ?DoctrineTagValueNode
{
$tokenIterator = $this->tokenIteratorFactory->create('/** ' . $tagName . $arguments . ' */');
$phpDocNode = $this->phpDocParser->parse($tokenIterator);

$phpDocChildNode = $phpDocNode->children[0] ?? null;
if (! $phpDocChildNode instanceof PhpDocTagNode) {
return null;
}

if (! $phpDocChildNode->value instanceof DoctrineTagValueNode) {
return null;
}

return $phpDocChildNode->value;
}

/**
* Known doc tag to annotation class
*/
private function resolveAnnotationClass(string $tagName, Node $phpNode): ?string
{
$fullyQualifiedAnnotationClass = $this->classAnnotationMatcher->resolveTagFullyQualifiedName(
$tagName,
$phpNode
);

if (\str_contains($fullyQualifiedAnnotationClass, '\\')) {
return $fullyQualifiedAnnotationClass;
}

if (in_array($fullyQualifiedAnnotationClass, self::ALLOWED_SHORT_ANNOTATIONS, true)) {
return $fullyQualifiedAnnotationClass;
}

return null;
}

/**
* @param ArrayItemNode[] $values
*/
Expand Down
Loading