diff --git a/config/level/symfony/symfony-code-quality.yaml b/config/level/symfony/symfony-code-quality.yaml index 20c89e6a70d3..3f57cb4e80ac 100644 --- a/config/level/symfony/symfony-code-quality.yaml +++ b/config/level/symfony/symfony-code-quality.yaml @@ -1,2 +1,3 @@ services: Rector\Symfony\Rector\BinaryOp\ResponseStatusCodeRector: ~ + Rector\Symfony\Rector\Class_\MakeCommandLazyRector: ~ diff --git a/ecs.yaml b/ecs.yaml index fb4383d5cd98..c26415a6dfab 100644 --- a/ecs.yaml +++ b/ecs.yaml @@ -101,6 +101,8 @@ parameters: Symplify\CodingStandard\Sniffs\CleanCode\CognitiveComplexitySniff: # tough logic + - 'packages/BetterPhpDocParser/src/' + - 'packages/Symfony/src/Rector/Class_/MakeCommandLazyRector.php' - 'packages/Legacy/src/Rector/ClassMethod/ChangeSingletonToServiceRector.php' - 'src/Rector/Psr4/MultipleClassFileToPsr4ClassesRector.php' - 'src/PhpParser/Node/Resolver/NameResolver.php' diff --git a/packages/BetterPhpDocParser/src/Attributes/Attribute/Attribute.php b/packages/BetterPhpDocParser/src/Attributes/Attribute/Attribute.php index 6f89b2fd088c..8c9534f3ac98 100644 --- a/packages/BetterPhpDocParser/src/Attributes/Attribute/Attribute.php +++ b/packages/BetterPhpDocParser/src/Attributes/Attribute/Attribute.php @@ -4,6 +4,11 @@ final class Attribute { + /** + * @var string + */ + public const HAS_DESCRIPTION_WITH_ORIGINAL_SPACES = 'has_description_with_restored_spaces'; + /** * Fully-qualified name * diff --git a/packages/BetterPhpDocParser/src/PhpDocParser/BetterPhpDocParser.php b/packages/BetterPhpDocParser/src/PhpDocParser/BetterPhpDocParser.php index 700ada154a2b..a0e4487c7a5d 100644 --- a/packages/BetterPhpDocParser/src/PhpDocParser/BetterPhpDocParser.php +++ b/packages/BetterPhpDocParser/src/PhpDocParser/BetterPhpDocParser.php @@ -6,6 +6,7 @@ use PHPStan\PhpDocParser\Ast\Node; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; use PHPStan\PhpDocParser\Lexer\Lexer; @@ -108,14 +109,23 @@ private function parseChildAndStoreItsPositions(TokenIterator $tokenIterator): N $attributeAwareNode = $this->attributeAwareNodeFactory->createFromNode($node); $attributeAwareNode->setAttribute(Attribute::PHP_DOC_NODE_INFO, new StartEndInfo($tokenStart, $tokenEnd)); - // add original text, for keeping trimmed spaces + $possibleMultilineText = null; + if ($attributeAwareNode instanceof PhpDocTagNode) { + if (property_exists($attributeAwareNode->value, 'description')) { + $possibleMultilineText = $attributeAwareNode->value->description; + } + } + if ($attributeAwareNode instanceof PhpDocTextNode) { - $originalContent = $this->getOriginalContentFromTokenIterator($tokenIterator); + $possibleMultilineText = $attributeAwareNode->text; + } - $currentText = $attributeAwareNode->text; + if ($possibleMultilineText) { + // add original text, for keeping trimmed spaces + $originalContent = $this->getOriginalContentFromTokenIterator($tokenIterator); // we try to match original content without trimmed spaces - $currentTextPattern = '#' . preg_quote($currentText, '#') . '#s'; + $currentTextPattern = '#' . preg_quote($possibleMultilineText, '#') . '#s'; $currentTextPattern = Strings::replace($currentTextPattern, '#\s#', '\s+'); $match = Strings::match($originalContent, $currentTextPattern); diff --git a/packages/BetterPhpDocParser/src/Printer/PhpDocInfoPrinter.php b/packages/BetterPhpDocParser/src/Printer/PhpDocInfoPrinter.php index 3462174442a2..63ec17d32b00 100644 --- a/packages/BetterPhpDocParser/src/Printer/PhpDocInfoPrinter.php +++ b/packages/BetterPhpDocParser/src/Printer/PhpDocInfoPrinter.php @@ -128,6 +128,7 @@ private function printNode( /** @var StartEndInfo|null $tokenStartEndInfo */ $startEndInfo = $attributeAwareNode->getAttribute(Attribute::PHP_DOC_NODE_INFO) ?: $startEndInfo; + $attributeAwareNode = $this->fixMultilineDescriptions($attributeAwareNode); if ($startEndInfo) { $isLastToken = ($nodeCount === $i); @@ -158,11 +159,6 @@ private function printNode( ); } - // fix multiline BC break - https://github.com/phpstan/phpdoc-parser/pull/26/files - if ($attributeAwareNode->getAttribute(Attribute::ORIGINAL_CONTENT)) { - $this->restoreOriginalSpacingInText($attributeAwareNode); - } - $content = (string) $attributeAwareNode; $content = explode(PHP_EOL, $content); $content = implode(PHP_EOL . ' * ', $content); @@ -209,6 +205,9 @@ private function addTokensFromTo( return $this->appendToOutput($output, $from, $to, $positionJumpSet); } + /** + * @param PhpDocTagNode|AttributeAwareNodeInterface $phpDocTagNode + */ private function printPhpDocTagNode( PhpDocTagNode $phpDocTagNode, StartEndInfo $startEndInfo, @@ -222,6 +221,17 @@ private function printPhpDocTagNode( $output .= ' '; } + if ($phpDocTagNode->getAttribute(Attribute::HAS_DESCRIPTION_WITH_ORIGINAL_SPACES)) { + if (property_exists($phpDocTagNode->value, 'description') && $phpDocTagNode->value->description) { + $pattern = Strings::replace($phpDocTagNode->value->description, '#[\s]+#', '\s+'); + $nodeOutput = Strings::replace($nodeOutput, '#' . $pattern . '#', $phpDocTagNode->value->description); + + if (substr_count($nodeOutput, "\n")) { + $nodeOutput = Strings::replace($nodeOutput, "#\n#", PHP_EOL . ' * '); + } + } + } + return $output . $nodeOutput; } @@ -290,17 +300,29 @@ private function isTagSeparatedBySpace(string $nodeOutput, PhpDocTagNode $phpDoc /** * @param PhpDocTextNode|AttributeAwareNodeInterface $attributeAwareNode */ - private function restoreOriginalSpacingInText(AttributeAwareNodeInterface $attributeAwareNode): void - { + private function restoreOriginalSpacingInText( + AttributeAwareNodeInterface $attributeAwareNode + ): ?AttributeAwareNodeInterface { /** @var string $originalContent */ $originalContent = $attributeAwareNode->getAttribute(Attribute::ORIGINAL_CONTENT); $oldSpaces = Strings::matchAll($originalContent, '#\s+#ms'); - $newParts = Strings::split($attributeAwareNode->text, '#\s+#'); + $currentText = null; + if ($attributeAwareNode instanceof PhpDocTagNode) { + if (property_exists($attributeAwareNode->value, 'description')) { + $currentText = $attributeAwareNode->value->description; + } + } + + if ($attributeAwareNode instanceof PhpDocTextNode) { + $currentText = $attributeAwareNode->text; + } + + $newParts = Strings::split($currentText, '#\s+#'); // we can't do this! if (count($oldSpaces) + 1 !== count($newParts)) { - return; + return null; } $newText = ''; @@ -317,7 +339,36 @@ private function restoreOriginalSpacingInText(AttributeAwareNodeInterface $attri } if ($newText) { - $attributeAwareNode->text = $newText; + if ($attributeAwareNode instanceof PhpDocTagNode) { + if (property_exists($attributeAwareNode->value, 'description')) { + $attributeAwareNode->value->description = $newText; + return $attributeAwareNode; + } + } + + if ($attributeAwareNode instanceof PhpDocTextNode) { + $attributeAwareNode->text = $newText; + return $attributeAwareNode; + } + } + + return null; + } + + /** + * Fix multiline BC break - https://github.com/phpstan/phpdoc-parser/pull/26/files + */ + private function fixMultilineDescriptions(AttributeAwareNodeInterface $attributeAwareNode): AttributeAwareNodeInterface + { + if (! $attributeAwareNode->getAttribute(Attribute::ORIGINAL_CONTENT)) { + return $attributeAwareNode; + } + + $nodeWithRestoredSpaces = $this->restoreOriginalSpacingInText($attributeAwareNode); + if ($nodeWithRestoredSpaces !== null) { + $attributeAwareNode = $nodeWithRestoredSpaces; + $attributeAwareNode->setAttribute(Attribute::HAS_DESCRIPTION_WITH_ORIGINAL_SPACES, true); } + return $attributeAwareNode; } } diff --git a/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline3.txt b/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline3.txt new file mode 100644 index 000000000000..52fdbcababce --- /dev/null +++ b/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline3.txt @@ -0,0 +1,4 @@ +/** + * @param string|null $channelId Also if we do the check in the self::execute method, + * allow for null to make PHPStan pass + */ diff --git a/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline4.txt b/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline4.txt new file mode 100644 index 000000000000..8079623ebddc --- /dev/null +++ b/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline4.txt @@ -0,0 +1,4 @@ +/** + * @return string|null Also if we do the check in the self::execute method, + * allow for null to make PHPStan pass + */ diff --git a/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline5.txt b/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline5.txt new file mode 100644 index 000000000000..6550a96019a2 --- /dev/null +++ b/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterSource/multiline5.txt @@ -0,0 +1,4 @@ +/** + * @deprecated Also if we do the check in the self::execute method, + * allow for null to make PHPStan pass + */ diff --git a/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterTest.php b/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterTest.php index aa890a0282d9..487b56f985ac 100644 --- a/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterTest.php +++ b/packages/BetterPhpDocParser/tests/PhpDocInfo/PhpDocInfoPrinterTest.php @@ -72,6 +72,9 @@ public function provideMultiline(): Iterator { yield [__DIR__ . '/PhpDocInfoPrinterSource/multiline1.txt']; yield [__DIR__ . '/PhpDocInfoPrinterSource/multiline2.txt']; + yield [__DIR__ . '/PhpDocInfoPrinterSource/multiline3.txt']; + yield [__DIR__ . '/PhpDocInfoPrinterSource/multiline4.txt']; + yield [__DIR__ . '/PhpDocInfoPrinterSource/multiline5.txt']; } /** diff --git a/packages/Symfony/src/Rector/Class_/MakeCommandLazyRector.php b/packages/Symfony/src/Rector/Class_/MakeCommandLazyRector.php new file mode 100644 index 000000000000..4177713370a8 --- /dev/null +++ b/packages/Symfony/src/Rector/Class_/MakeCommandLazyRector.php @@ -0,0 +1,155 @@ +callableNodeTraverser = $callableNodeTraverser; + } + + public function getDefinition(): RectorDefinition + { + return new RectorDefinition('Make Symfony commands lazy', [ + new CodeSample( + <<<'CODE_SAMPLE' +use Symfony\Component\Console\Command\Command + +class SunshineCommand extends Command +{ + public function configure() + { + $this->setName('sunshine'); + } +} +CODE_SAMPLE + , + <<<'CODE_SAMPLE' +use Symfony\Component\Console\Command\Command + +class SunshineCommand extends Command +{ + protected static $defaultName = 'sunshine'; + public function configure() + { + } +} +CODE_SAMPLE + ), + ]); + } + + /** + * @return string[] + */ + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): ?Node + { + if (! $this->isType($node, self::COMMAND_CLASS)) { + return null; + } + + $commandName = $this->resolveCommandNameAndRemove($node); + if ($commandName === null) { + return null; + } + + $defaultNameProperty = $this->createDefaultNameProperty($commandName); + $node->stmts = array_merge([$defaultNameProperty], (array) $node->stmts); + + return $node; + } + + private function createDefaultNameProperty(Node $commandNameNode): Node\Stmt\Property + { + return $this->builderFactory->property('defaultName') + ->makeProtected() + ->makeStatic() + ->setDefault($commandNameNode) + ->getNode(); + } + + private function resolveCommandNameAndRemove(Class_ $class): ?Node + { + $commandName = null; + $this->callableNodeTraverser->traverseNodesWithCallable((array) $class->stmts, function (Node $node) use ( + &$commandName + ) { + if ($node instanceof MethodCall) { + if (! $this->isName($node, 'setName')) { + return null; + } + + $commandName = $node->args[0]->value; + $this->removeNode($node); + } + + if ($node instanceof ClassMethod && $this->isName($node, '__construct')) { + if (count((array) $node->stmts) !== 1) { + return null; + } + + $onlyNode = $node->stmts[0]; + if ($onlyNode instanceof Expression) { + $onlyNode = $onlyNode->expr; + if (! $this->isName($onlyNode, '__construct')) { + return null; + } + + $commandName = $onlyNode->args[0]->value; + if (! is_string($this->getValue($commandName))) { + return null; + } + + if (count($node->params) === 0) { + $this->removeNode($node); + } + } + } + + if ($node instanceof StaticCall) { + if (! $this->isName($node, '__construct')) { + return null; + } + + $commandName = $node->args[0]->value; + + array_shift($node->args); + } + }); + + return $commandName; + } +} diff --git a/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/constant_defined_name.php.inc b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/constant_defined_name.php.inc new file mode 100644 index 000000000000..cdc15566ab55 --- /dev/null +++ b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/constant_defined_name.php.inc @@ -0,0 +1,31 @@ +setName(self::COMMAND_NAME); + } +} + +?> +----- + diff --git a/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/fixture.php.inc b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/fixture.php.inc new file mode 100644 index 000000000000..c97d75b52614 --- /dev/null +++ b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/fixture.php.inc @@ -0,0 +1,27 @@ +setName('sunshine'); + } +} + +?> +----- + diff --git a/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/in_construct.php.inc b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/in_construct.php.inc new file mode 100644 index 000000000000..ab1a86e13fc6 --- /dev/null +++ b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/in_construct.php.inc @@ -0,0 +1,28 @@ + +----- + diff --git a/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/in_construct_with_param.php.inc b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/in_construct_with_param.php.inc new file mode 100644 index 000000000000..d94ccc620034 --- /dev/null +++ b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/Fixture/in_construct_with_param.php.inc @@ -0,0 +1,38 @@ +someService = $someService; + } +} + +?> +----- +someService = $someService; + } +} + +?> diff --git a/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/MakeCommandLazyRectorTest.php b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/MakeCommandLazyRectorTest.php new file mode 100644 index 000000000000..f4b9cad3bcfa --- /dev/null +++ b/packages/Symfony/tests/Rector/Class_/MakeCommandLazyRector/MakeCommandLazyRectorTest.php @@ -0,0 +1,24 @@ +doTestFiles([ + __DIR__ . '/Fixture/fixture.php.inc', + __DIR__ . '/Fixture/in_construct.php.inc', + __DIR__ . '/Fixture/in_construct_with_param.php.inc', + __DIR__ . '/Fixture/constant_defined_name.php.inc', + ]); + } + + protected function getRectorClass(): string + { + return MakeCommandLazyRector::class; + } +}