diff --git a/composer.json b/composer.json index 6c7c26dc..b3b44cfe 100644 --- a/composer.json +++ b/composer.json @@ -8,21 +8,24 @@ "ext-xml": "*" }, "require-dev": { + "phpecs/phpecs": "^2.0.1", "phpstan/extension-installer": "^1.4", "phpstan/phpstan": "^2.1.8", "phpstan/phpstan-webmozart-assert": "^2.0", "phpunit/phpunit": "^11.4", "rector/rector-src": "dev-main", + "rector/type-perfect": "^2.0", "symfony/config": "^6.4", "symfony/dependency-injection": "^6.4", - "symfony/http-kernel": "~6.3", + "symfony/http-kernel": "^6.4", "symfony/routing": "^6.4", "symfony/security-core": "^6.4", "symfony/security-http": "^6.4", "symfony/validator": "^6.4", - "symplify/easy-coding-standard": "^12.3", "symplify/vendor-patches": "^11.3", - "tomasvotruba/class-leak": "^1.0", + "tomasvotruba/class-leak": "^2.0", + "tomasvotruba/type-coverage": "^2.0", + "tomasvotruba/unused-public": "^2.0", "tracy/tracy": "^2.10" }, "autoload": { diff --git a/config/sets/symfony/symfony73.php b/config/sets/symfony/symfony73.php new file mode 100644 index 00000000..0d87fd60 --- /dev/null +++ b/config/sets/symfony/symfony73.php @@ -0,0 +1,11 @@ +withRules([InvokableCommandRector::class]); diff --git a/phpstan.neon b/phpstan.neon index 1c88c994..38b98277 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -2,7 +2,6 @@ parameters: level: 8 reportUnmatchedIgnoredErrors: false - treatPhpDocTypesAsCertain: false paths: @@ -12,18 +11,17 @@ parameters: - rules - rules-tests -# to be enabled later once rector upgraded to use phpstan v2 -# # https://github.com/rectorphp/type-perfect/ -# type_perfect: -# no_mixed: true -# null_over_false: true -# narrow_param: true -# narrow_return: true + # https://github.com/rectorphp/type-perfect/ + type_perfect: + no_mixed: true + null_over_false: true + narrow_param: true + narrow_return: true -# unused_public: -# constants: true -# methods: true -# properties: true + unused_public: + constants: true + methods: true + properties: true scanDirectories: - stubs @@ -54,16 +52,12 @@ parameters: - '#Doing instanceof PHPStan\\Type\\.+ is error\-prone and deprecated#' # phpstan instanceof - - - identifier: phpstanApi.instanceofAssumption - - - - identifier: phpstanApi.varTagAssumption + - identifier: argument.type + - identifier: assign.propertyType - - - identifier: argument.type + - '#::provideMinPhpVersion\(\) never returns \d+ so it can be removed from the return type#' + # node finder - - identifier: assign.propertyType - - - '#::provideMinPhpVersion\(\) never returns \d+ so it can be removed from the return type#' + identifier: return.type + path: rules/Symfony73/NodeAnalyzer/CommandArgumentsAndOptionsResolver.php diff --git a/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/Fixture/some_command.php.inc b/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/Fixture/some_command.php.inc new file mode 100644 index 00000000..700d41aa --- /dev/null +++ b/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/Fixture/some_command.php.inc @@ -0,0 +1,61 @@ +addArgument('argument', InputArgument::REQUIRED, 'Argument description'); + $this->addOption('option', 'o', InputOption::VALUE_NONE, 'Option description'); + } + + public function execute(InputInterface $input, OutputInterface $output): int + { + $someArgument = $input->getArgument('argument'); + $someOption = $input->getOption('option'); + + // ... + + return 1; + } +} + +?> +----- + diff --git a/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/InvokableCommandRectorTest.php b/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/InvokableCommandRectorTest.php new file mode 100644 index 00000000..f10fe23e --- /dev/null +++ b/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/InvokableCommandRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/config/configured_rule.php b/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/config/configured_rule.php new file mode 100644 index 00000000..a96cce4b --- /dev/null +++ b/rules-tests/Symfony73/Rector/Class_/InvokableCommandRector/config/configured_rule.php @@ -0,0 +1,10 @@ +rule(InvokableCommandRector::class); +}; diff --git a/rules/Symfony61/Rector/Class_/CommandConfigureToAttributeRector.php b/rules/Symfony61/Rector/Class_/CommandConfigureToAttributeRector.php index c37df3dd..6025824b 100644 --- a/rules/Symfony61/Rector/Class_/CommandConfigureToAttributeRector.php +++ b/rules/Symfony61/Rector/Class_/CommandConfigureToAttributeRector.php @@ -19,14 +19,15 @@ use PHPStan\Type\ObjectType; use Rector\PhpAttribute\NodeFactory\PhpAttributeGroupFactory; use Rector\Rector\AbstractRector; -use Rector\Symfony\Enum\SymfonyAnnotation; +use Rector\Symfony\Enum\SymfonyAttribute; +use Rector\Symfony\Enum\SymfonyClass; use Rector\ValueObject\PhpVersionFeature; use Rector\VersionBonding\Contract\MinPhpVersionInterface; use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample; use Symplify\RuleDocGenerator\ValueObject\RuleDefinition; /** - * @changelog https://symfony.com/doc/current/console.html#registering-the-command + * @see https://symfony.com/doc/current/console.html#registering-the-command * * @see \Rector\Symfony\Tests\Symfony61\Rector\Class_\CommandConfigureToAttributeRector\CommandConfigureToAttributeRectorTest */ @@ -102,11 +103,11 @@ public function refactor(Node $node): ?Node return null; } - if (! $this->reflectionProvider->hasClass(SymfonyAnnotation::AS_COMMAND)) { + if (! $this->reflectionProvider->hasClass(SymfonyAttribute::AS_COMMAND)) { return null; } - if (! $this->isObjectType($node, new ObjectType('Symfony\\Component\\Console\\Command\\Command'))) { + if (! $this->isObjectType($node, new ObjectType(SymfonyClass::COMMAND))) { return null; } @@ -120,7 +121,7 @@ public function refactor(Node $node): ?Node $attributeArgs = []; foreach ($node->attrGroups as $attrGroup) { foreach ($attrGroup->attrs as $attribute) { - if (! $this->nodeNameResolver->isName($attribute->name, SymfonyAnnotation::AS_COMMAND)) { + if (! $this->nodeNameResolver->isName($attribute->name, SymfonyAttribute::AS_COMMAND)) { continue; } @@ -139,7 +140,7 @@ public function refactor(Node $node): ?Node } if (! $asCommandAttribute instanceof Attribute) { - $asCommandAttributeGroup = $this->phpAttributeGroupFactory->createFromClass(SymfonyAnnotation::AS_COMMAND); + $asCommandAttributeGroup = $this->phpAttributeGroupFactory->createFromClass(SymfonyAttribute::AS_COMMAND); $asCommandAttribute = $asCommandAttributeGroup->attrs[0]; diff --git a/rules/Symfony61/Rector/Class_/CommandPropertyToAttributeRector.php b/rules/Symfony61/Rector/Class_/CommandPropertyToAttributeRector.php index 5c1a0fce..879fcd57 100644 --- a/rules/Symfony61/Rector/Class_/CommandPropertyToAttributeRector.php +++ b/rules/Symfony61/Rector/Class_/CommandPropertyToAttributeRector.php @@ -17,7 +17,7 @@ use Rector\Doctrine\NodeAnalyzer\AttributeFinder; use Rector\PhpAttribute\NodeFactory\PhpAttributeGroupFactory; use Rector\Rector\AbstractRector; -use Rector\Symfony\Enum\SymfonyAnnotation; +use Rector\Symfony\Enum\SymfonyAttribute; use Rector\Symfony\Enum\SymfonyClass; use Rector\ValueObject\PhpVersionFeature; use Rector\VersionBonding\Contract\MinPhpVersionInterface; @@ -91,7 +91,7 @@ public function refactor(Node $node): ?Node } // does attribute already exist? - if (! $this->reflectionProvider->hasClass(SymfonyAnnotation::AS_COMMAND)) { + if (! $this->reflectionProvider->hasClass(SymfonyAttribute::AS_COMMAND)) { return null; } @@ -104,7 +104,7 @@ public function refactor(Node $node): ?Node $existingAsCommandAttribute = $this->attributeFinder->findAttributeByClass( $node, - SymfonyAnnotation::AS_COMMAND + SymfonyAttribute::AS_COMMAND ); $attributeArgs = $this->createAttributeArgs($defaultNameExpr, $defaultDescriptionExpr); @@ -126,7 +126,7 @@ private function createAttributeGroupAsCommand(array $args): AttributeGroup { Assert::allIsInstanceOf($args, Arg::class); - $attributeGroup = $this->phpAttributeGroupFactory->createFromClass(SymfonyAnnotation::AS_COMMAND); + $attributeGroup = $this->phpAttributeGroupFactory->createFromClass(SymfonyAttribute::AS_COMMAND); $attributeGroup->attrs[0]->args = $args; return $attributeGroup; diff --git a/rules/Symfony73/NodeAnalyzer/CommandArgumentsAndOptionsResolver.php b/rules/Symfony73/NodeAnalyzer/CommandArgumentsAndOptionsResolver.php new file mode 100644 index 00000000..dd13bce2 --- /dev/null +++ b/rules/Symfony73/NodeAnalyzer/CommandArgumentsAndOptionsResolver.php @@ -0,0 +1,90 @@ +findMethodCallsByName($configureClassMethod, 'addArgument'); + + $commandArguments = []; + foreach ($addArgumentMethodCalls as $addArgumentMethodCall) { + // @todo extract name, type and requirements + $addArgumentArgs = $addArgumentMethodCall->getArgs(); + + $nameArgValue = $addArgumentArgs[0]->value; + if (! $nameArgValue instanceof String_) { + // we need string value, otherwise param will not have a name + throw new ShouldNotHappenException('Argument name is required'); + } + + $optionName = $nameArgValue->value; + + $commandArguments[] = new CommandArgument($optionName); + } + + return $commandArguments; + } + + /** + * @return CommandOption[] + */ + public function collectCommandOptions(ClassMethod $configureClassMethod): array + { + $addOptionMethodCalls = $this->findMethodCallsByName($configureClassMethod, 'addOption'); + + $commandOptionMetadatas = []; + foreach ($addOptionMethodCalls as $addOptionMethodCall) { + // @todo extract name, type and requirements + $addOptionArgs = $addOptionMethodCall->getArgs(); + + $nameArgValue = $addOptionArgs[0]->value; + if (! $nameArgValue instanceof String_) { + // we need string value, otherwise param will not have a name + throw new ShouldNotHappenException('Option name is required'); + } + + $optionName = $nameArgValue->value; + + $commandOptionMetadatas[] = new CommandOption($optionName); + } + + return $commandOptionMetadatas; + } + + /** + * @return MethodCall[] + */ + private function findMethodCallsByName(ClassMethod $classMethod, string $desiredMethodName): array + { + $nodeFinder = new NodeFinder(); + + return $nodeFinder->find($classMethod, function (Node $node) use ($desiredMethodName): bool { + if (! $node instanceof MethodCall) { + return false; + } + + if (! $node->name instanceof Identifier) { + return false; + } + + return $node->name->toString() === $desiredMethodName; + }); + } +} diff --git a/rules/Symfony73/NodeFactory/CommandInvokeParamsFactory.php b/rules/Symfony73/NodeFactory/CommandInvokeParamsFactory.php new file mode 100644 index 00000000..08537f15 --- /dev/null +++ b/rules/Symfony73/NodeFactory/CommandInvokeParamsFactory.php @@ -0,0 +1,78 @@ +createArgumentParams($commandArguments); + $optionParams = $this->createOptionParams($commandOptions); + + return array_merge($argumentParams, $optionParams); + } + + /** + * @param CommandArgument[] $commandArguments + * @return Param[] + */ + private function createArgumentParams(array $commandArguments): array + { + $argumentParams = []; + + foreach ($commandArguments as $commandArgument) { + $argumentParam = new Param(new Variable($commandArgument->getName())); + + $argumentParam->type = new Identifier('string'); + // @todo fill type or default value + // @todo default string, multiple values array + + $argumentParam->attrGroups[] = new AttributeGroup([ + new Attribute(new FullyQualified(SymfonyAttribute::COMMAND_ARGUMENT)), + ]); + + $argumentParams[] = $argumentParam; + } + + return $argumentParams; + } + + /** + * @param CommandOption[] $commandOptions + * @return Param[] + */ + private function createOptionParams(array $commandOptions): array + { + $optionParams = []; + + foreach ($commandOptions as $commandOption) { + $optionParam = new Param(new Variable($commandOption->getName())); + + // @todo fill type or default value + $optionParam->attrGroups[] = new AttributeGroup([ + new Attribute(new FullyQualified(SymfonyAttribute::COMMAND_OPTION)), + ]); + + $optionParams[] = $optionParam; + } + + return $optionParams; + } +} diff --git a/rules/Symfony73/Rector/Class_/InvokableCommandRector.php b/rules/Symfony73/Rector/Class_/InvokableCommandRector.php new file mode 100644 index 00000000..a1ba97bd --- /dev/null +++ b/rules/Symfony73/Rector/Class_/InvokableCommandRector.php @@ -0,0 +1,222 @@ +addArgument('argument', InputArgument::REQUIRED, 'Argument description'); + $this->addOption('option', 'o', InputOption::VALUE_NONE, 'Option description'); + } + + public function execute(InputInterface $input, OutputInterface $output) + { + $someArgument = $input->getArgument('argument'); + $someOption = $input->getOption('option'); + + // ... + + return 1; + } +} +CODE_SAMPLE + , + <<<'CODE_SAMPLE' +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Command\Argument; +use Symfony\Component\Console\Command\Option; + +final class SomeCommand +{ + public function __invoke( + #[Argument] + string $argument, + #[Option] + bool $option = false, + ) { + $someArgument = $argument; + $someOption = $option; + + // ... + + return 1; + } +} +CODE_SAMPLE + ), + ]); + } + + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): ?Class_ + { + if (! $node->extends instanceof Name) { + return null; + } + + // handle only direct child classes, to keep safe + if (! $this->isName($node->extends, SymfonyClass::COMMAND)) { + return null; + } + + if ($this->isComplexCommand($node)) { + return null; + } + + // as command attribute is required, its handled by previous symfony versions + // @todo possibly to add it here to handle multiple cases + if (! $this->attributeFinder->findAttributeByClass($node, SymfonyAttribute::AS_COMMAND) instanceof Attribute) { + return null; + } + + // 1. fetch configure method to get arguments and options metadata + $configureClassMethod = $node->getMethod(CommandMethodName::CONFIGURE); + if (! $configureClassMethod instanceof ClassMethod) { + return null; + } + + // 2. rename execute to __invoke + $executeClassMethod = $node->getMethod(CommandMethodName::EXECUTE); + if (! $executeClassMethod instanceof ClassMethod) { + return null; + } + + $executeClassMethod->name = new Identifier('__invoke'); + + // 3. create arguments and options parameters + // @todo + $commandArguments = $this->commandArgumentsAndOptionsResolver->collectCommandArguments( + $configureClassMethod + ); + + $commandOptions = $this->commandArgumentsAndOptionsResolver->collectCommandOptions($configureClassMethod); + + // 4. remove configure() method + $this->removeConfigureClassMethod($node); + + // 5. decorate __invoke method with attributes + $invokeParams = $this->commandInvokeParamsFactory->createParams($commandArguments, $commandOptions); + $executeClassMethod->params = $invokeParams; + + // 6. remove parent class + $node->extends = null; + + // 7. replace input->getArgument() and input->getOption() calls with direct variable access + $this->replaceInputArgumentOptionFetchWithVariables($executeClassMethod); + + return $node; + } + + /** + * Skip commands with interact() or initialize() methods as modify the argument/option values + */ + private function isComplexCommand(Class_ $class): bool + { + if ($class->getMethod(CommandMethodName::INTERACT) instanceof ClassMethod) { + return true; + } + + return $class->getMethod(CommandMethodName::INITIALIZE) instanceof ClassMethod; + } + + private function removeConfigureClassMethod(Class_ $class): void + { + foreach ($class->stmts as $key => $stmt) { + if (! $stmt instanceof ClassMethod) { + continue; + } + + if (! $this->isName($stmt->name, CommandMethodName::CONFIGURE)) { + continue; + } + + unset($class->stmts[$key]); + return; + } + } + + private function replaceInputArgumentOptionFetchWithVariables(ClassMethod $executeClassMethod): void + { + $this->traverseNodesWithCallable($executeClassMethod->stmts, function (Node $node): ?Variable { + if (! $node instanceof MethodCall) { + return null; + } + + if (! $this->isName($node->var, 'input')) { + return null; + } + + if (! $this->isNames($node->name, ['getOption', 'getArgument'])) { + return null; + } + + $firstArgValue = $node->getArgs()[0] + ->value; + + if (! $firstArgValue instanceof String_) { + // unable to resolve argument/option name + throw new ShouldNotHappenException(); + } + + return new Variable($firstArgValue->value); + }); + } +} diff --git a/rules/Symfony73/ValueObject/CommandArgument.php b/rules/Symfony73/ValueObject/CommandArgument.php new file mode 100644 index 00000000..33bfc873 --- /dev/null +++ b/rules/Symfony73/ValueObject/CommandArgument.php @@ -0,0 +1,20 @@ +name; + } +} diff --git a/rules/Symfony73/ValueObject/CommandOption.php b/rules/Symfony73/ValueObject/CommandOption.php new file mode 100644 index 00000000..b6880612 --- /dev/null +++ b/rules/Symfony73/ValueObject/CommandOption.php @@ -0,0 +1,20 @@ +name; + } +} diff --git a/src/Enum/CommandMethodName.php b/src/Enum/CommandMethodName.php new file mode 100644 index 00000000..e276426c --- /dev/null +++ b/src/Enum/CommandMethodName.php @@ -0,0 +1,16 @@ +