diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 5a86acb542197..38d37c7fc1990 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -49,6 +49,7 @@ use Symfony\Component\Config\Resource\FileResource; use Symfony\Component\Config\ResourceCheckerInterface; use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\DataCollector\CommandDataCollector; use Symfony\Component\Console\Debug\CliRequest; @@ -608,6 +609,9 @@ public function load(array $configs, ContainerBuilder $container): void ->addTag('assets.package'); $container->registerForAutoconfiguration(AssetCompilerInterface::class) ->addTag('asset_mapper.compiler'); + $container->registerAttributeForAutoconfiguration(AsCommand::class, static function (ChildDefinition $definition, AsCommand $attribute, \ReflectionClass $reflector): void { + $definition->addTag('console.command', ['command' => $attribute->name, 'description' => $attribute->description ?? $reflector->getName()]); + }); $container->registerForAutoconfiguration(Command::class) ->addTag('console.command'); $container->registerForAutoconfiguration(ResourceCheckerInterface::class) diff --git a/src/Symfony/Component/Console/Attribute/Argument.php b/src/Symfony/Component/Console/Attribute/Argument.php new file mode 100644 index 0000000000000..c32d45c19aaa5 --- /dev/null +++ b/src/Symfony/Component/Console/Attribute/Argument.php @@ -0,0 +1,104 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Attribute; + +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class Argument +{ + private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; + + private ?int $mode = null; + + /** + * Represents a console command definition. + * + * If unset, the `name` and `default` values will be inferred from the parameter definition. + * + * @param string|bool|int|float|array|null $default The default value (for InputArgument::OPTIONAL mode only) + * @param array|callable-string(CompletionInput):list $suggestedValues The values used for input completion + */ + public function __construct( + public string $name = '', + public string $description = '', + public string|bool|int|float|array|null $default = null, + public array|string $suggestedValues = [], + ) { + if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) { + throw new \TypeError(\sprintf('Argument 4 passed to "%s()" must be either an array or a callable-string.', __METHOD__)); + } + } + + /** + * @internal + */ + public static function tryFrom(\ReflectionParameter $parameter): ?self + { + /** @var self $self */ + if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) { + return null; + } + + $type = $parameter->getType(); + $name = $parameter->getName(); + + if (!$type instanceof \ReflectionNamedType) { + throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name)); + } + + $parameterTypeName = $type->getName(); + + if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) { + throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, implode('", "', self::ALLOWED_TYPES))); + } + + if (!$self->name) { + $self->name = $name; + } + + $self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; + if ('array' === $parameterTypeName) { + $self->mode |= InputArgument::IS_ARRAY; + } + + $self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + + if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { + $self->suggestedValues = [$instance, $self->suggestedValues[1]]; + } + + return $self; + } + + /** + * @internal + */ + public function toInputArgument(): InputArgument + { + $suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues; + + return new InputArgument($this->name, $this->mode, $this->description, $this->default, $suggestedValues); + } + + /** + * @internal + */ + public function resolveValue(InputInterface $input): mixed + { + return $input->hasArgument($this->name) ? $input->getArgument($this->name) : null; + } +} diff --git a/src/Symfony/Component/Console/Attribute/Option.php b/src/Symfony/Component/Console/Attribute/Option.php new file mode 100644 index 0000000000000..98d074b9dd48f --- /dev/null +++ b/src/Symfony/Component/Console/Attribute/Option.php @@ -0,0 +1,119 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Attribute; + +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class Option +{ + private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; + + private ?int $mode = null; + private string $typeName = ''; + + /** + * Represents a console command --option definition. + * + * If unset, the `name` and `default` values will be inferred from the parameter definition. + * + * @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param scalar|array|null $default The default value (must be null for self::VALUE_NONE) + * @param array|callable-string(CompletionInput):list $suggestedValues The values used for input completion + */ + public function __construct( + public string $name = '', + public array|string|null $shortcut = null, + public string $description = '', + public string|bool|int|float|array|null $default = null, + public array|string $suggestedValues = [], + ) { + if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) { + throw new \TypeError(\sprintf('Argument 5 passed to "%s()" must be either an array or a callable-string.', __METHOD__)); + } + } + + /** + * @internal + */ + public static function tryFrom(\ReflectionParameter $parameter): ?self + { + /** @var self $self */ + if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) { + return null; + } + + $type = $parameter->getType(); + $name = $parameter->getName(); + + if (!$type instanceof \ReflectionNamedType) { + throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command options.', $name)); + } + + $self->typeName = $type->getName(); + + if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) { + throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES))); + } + + if (!$self->name) { + $self->name = $name; + } + + if ('bool' === $self->typeName) { + $self->mode = InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE; + } else { + $self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED; + if ('array' === $self->typeName) { + $self->mode |= InputOption::VALUE_IS_ARRAY; + } + } + + if (InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $self->mode)) { + $self->default = null; + } else { + $self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + } + + if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { + $self->suggestedValues = [$instance, $self->suggestedValues[1]]; + } + + return $self; + } + + /** + * @internal + */ + public function toInputOption(): InputOption + { + $suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues; + + return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestedValues); + } + + /** + * @internal + */ + public function resolveValue(InputInterface $input): mixed + { + if ('bool' === $this->typeName) { + return $input->hasOption($this->name) && null !== $input->getOption($this->name) ? $input->getOption($this->name) : ($this->default ?? false); + } + + return $input->hasOption($this->name) ? $input->getOption($this->name) : null; + } +} diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index 2c963568c999a..a8837b528a0db 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +7.3 +--- + +* Add support for invokable commands +* Add `#[Argument]` and `#[Option]` attributes to define input arguments and options for invokable commands + 7.2 --- diff --git a/src/Symfony/Component/Console/Command/Command.php b/src/Symfony/Component/Console/Command/Command.php index 244a419f2e519..27d0651fae60c 100644 --- a/src/Symfony/Component/Console/Command/Command.php +++ b/src/Symfony/Component/Console/Command/Command.php @@ -49,7 +49,7 @@ class Command private string $description = ''; private ?InputDefinition $fullDefinition = null; private bool $ignoreValidationErrors = false; - private ?\Closure $code = null; + private ?InvokableCommand $code = null; private array $synopsis = []; private array $usages = []; private ?HelperSet $helperSet = null; @@ -164,6 +164,9 @@ public function isEnabled(): bool */ protected function configure() { + if (!$this->code && \is_callable($this)) { + $this->code = new InvokableCommand($this, $this(...)); + } } /** @@ -274,12 +277,10 @@ public function run(InputInterface $input, OutputInterface $output): int $input->validate(); if ($this->code) { - $statusCode = ($this->code)($input, $output); - } else { - $statusCode = $this->execute($input, $output); + return ($this->code)($input, $output); } - return is_numeric($statusCode) ? (int) $statusCode : 0; + return $this->execute($input, $output); } /** @@ -327,7 +328,7 @@ public function setCode(callable $code): static $code = $code(...); } - $this->code = $code; + $this->code = new InvokableCommand($this, $code); return $this; } @@ -395,7 +396,13 @@ public function getDefinition(): InputDefinition */ public function getNativeDefinition(): InputDefinition { - return $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class)); + $definition = $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class)); + + if ($this->code && !$definition->getArguments() && !$definition->getOptions()) { + $this->code->configure($definition); + } + + return $definition; } /** diff --git a/src/Symfony/Component/Console/Command/InvokableCommand.php b/src/Symfony/Component/Console/Command/InvokableCommand.php new file mode 100644 index 0000000000000..6c7136fda60d3 --- /dev/null +++ b/src/Symfony/Component/Console/Command/InvokableCommand.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Command; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Represents an invokable command. + * + * @author Yonel Ceruto + * + * @internal + */ +class InvokableCommand +{ + private readonly \ReflectionFunction $reflection; + + public function __construct( + private readonly Command $command, + private readonly \Closure $code, + ) { + $this->reflection = new \ReflectionFunction($code); + } + + /** + * Invokes a callable with parameters generated from the input interface. + */ + public function __invoke(InputInterface $input, OutputInterface $output): int + { + $statusCode = ($this->code)(...$this->getParameters($input, $output)); + + if (null !== $statusCode && !\is_int($statusCode)) { + // throw new LogicException(\sprintf('The command "%s" must return either void or an integer value in the "%s" method, but "%s" was returned.', $this->command->getName(), $this->reflection->getName(), get_debug_type($statusCode))); + trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in PHP 8.0.', $this->command->getName())); + + return 0; + } + + return $statusCode ?? 0; + } + + /** + * Configures the input definition from an invokable-defined function. + * + * Processes the parameters of the reflection function to extract and + * add arguments or options to the provided input definition. + */ + public function configure(InputDefinition $definition): void + { + foreach ($this->reflection->getParameters() as $parameter) { + if ($argument = Argument::tryFrom($parameter)) { + $definition->addArgument($argument->toInputArgument()); + } elseif ($option = Option::tryFrom($parameter)) { + $definition->addOption($option->toInputOption()); + } + } + } + + private function getParameters(InputInterface $input, OutputInterface $output): array + { + $parameters = []; + foreach ($this->reflection->getParameters() as $parameter) { + if ($argument = Argument::tryFrom($parameter)) { + $parameters[] = $argument->resolveValue($input); + + continue; + } + + if ($option = Option::tryFrom($parameter)) { + $parameters[] = $option->resolveValue($input); + + continue; + } + + $type = $parameter->getType(); + + if (!$type instanceof \ReflectionNamedType) { + // throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported.', $parameter->getName())); + trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in PHP 8.0.', $parameter->getName())); + + continue; + } + + $parameters[] = match ($type->getName()) { + InputInterface::class => $input, + OutputInterface::class => $output, + SymfonyStyle::class => new SymfonyStyle($input, $output), + Application::class => $this->command->getApplication(), + default => throw new RuntimeException(\sprintf('Unsupported type "%s" for parameter "$%s".', $type->getName(), $parameter->getName())), + }; + } + + return $parameters ?: [$input, $output]; + } +} diff --git a/src/Symfony/Component/Console/DependencyInjection/AddConsoleCommandPass.php b/src/Symfony/Component/Console/DependencyInjection/AddConsoleCommandPass.php index f1521602a8e1b..78e355ad8a2c2 100644 --- a/src/Symfony/Component/Console/DependencyInjection/AddConsoleCommandPass.php +++ b/src/Symfony/Component/Console/DependencyInjection/AddConsoleCommandPass.php @@ -41,18 +41,21 @@ public function process(ContainerBuilder $container): void $definition->addTag('container.no_preload'); $class = $container->getParameterBag()->resolveValue($definition->getClass()); - if (isset($tags[0]['command'])) { - $aliases = $tags[0]['command']; - } else { - if (!$r = $container->getReflectionClass($class)) { - throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); - } - if (!$r->isSubclassOf(Command::class)) { - throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must be a subclass of "%s".', $id, 'console.command', Command::class)); + if (!$r = $container->getReflectionClass($class)) { + throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); + } + + if (!$r->isSubclassOf(Command::class)) { + if (!$r->hasMethod('__invoke')) { + throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must either be a subclass of "%s" or have an "__invoke()" method.', $id, 'console.command', Command::class)); } - $aliases = str_replace('%', '%%', $class::getDefaultName() ?? ''); + + $invokableRef = new Reference($id); + $definition = $container->register($id .= '.command', $class = Command::class) + ->addMethodCall('setCode', [$invokableRef]); } + $aliases = $tags[0]['command'] ?? str_replace('%', '%%', $class::getDefaultName() ?? ''); $aliases = explode('|', $aliases); $commandName = array_shift($aliases); diff --git a/src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php b/src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php new file mode 100644 index 0000000000000..e6292c60d8476 --- /dev/null +++ b/src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\LogicException; + +class InvokableCommandTest extends TestCase +{ + public function testCommandInputArgumentDefinition() + { + $command = new Command('foo'); + $command->setCode(function ( + #[Argument(name: 'first-name')] string $name, + #[Argument(default: '')] string $lastName, + #[Argument(description: 'Short argument description')] string $bio = '', + #[Argument(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], + ) {}); + + $nameInputArgument = $command->getDefinition()->getArgument('first-name'); + self::assertSame('first-name', $nameInputArgument->getName()); + self::assertTrue($nameInputArgument->isRequired()); + + $lastNameInputArgument = $command->getDefinition()->getArgument('lastName'); + self::assertSame('lastName', $lastNameInputArgument->getName()); + self::assertFalse($lastNameInputArgument->isRequired()); + self::assertSame('', $lastNameInputArgument->getDefault()); + + $bioInputArgument = $command->getDefinition()->getArgument('bio'); + self::assertSame('bio', $bioInputArgument->getName()); + self::assertFalse($bioInputArgument->isRequired()); + self::assertSame('Short argument description', $bioInputArgument->getDescription()); + self::assertSame('', $bioInputArgument->getDefault()); + + $rolesInputArgument = $command->getDefinition()->getArgument('roles'); + self::assertSame('roles', $rolesInputArgument->getName()); + self::assertFalse($rolesInputArgument->isRequired()); + self::assertTrue($rolesInputArgument->isArray()); + self::assertSame(['ROLE_USER'], $rolesInputArgument->getDefault()); + self::assertTrue($rolesInputArgument->hasCompletion()); + $rolesInputArgument->complete(new CompletionInput(), $suggestions = new CompletionSuggestions()); + self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions())); + } + + public function testCommandInputOptionDefinition() + { + $command = new Command('foo'); + $command->setCode(function ( + #[Option(name: 'idle')] int $timeout, + #[Option(default: 'USER_TYPE')] string $type, + #[Option(shortcut: 'v')] bool $verbose = false, + #[Option(description: 'User groups')] array $groups = [], + #[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], + ) {}); + + $timeoutInputOption = $command->getDefinition()->getOption('idle'); + self::assertSame('idle', $timeoutInputOption->getName()); + self::assertNull($timeoutInputOption->getShortcut()); + self::assertTrue($timeoutInputOption->isValueRequired()); + self::assertNull($timeoutInputOption->getDefault()); + + $typeInputOption = $command->getDefinition()->getOption('type'); + self::assertSame('type', $typeInputOption->getName()); + self::assertFalse($typeInputOption->isValueRequired()); + self::assertSame('USER_TYPE', $typeInputOption->getDefault()); + + $verboseInputOption = $command->getDefinition()->getOption('verbose'); + self::assertSame('verbose', $verboseInputOption->getName()); + self::assertSame('v', $verboseInputOption->getShortcut()); + self::assertFalse($verboseInputOption->isValueRequired()); + self::assertTrue($verboseInputOption->isNegatable()); + self::assertNull($verboseInputOption->getDefault()); + + $groupsInputOption = $command->getDefinition()->getOption('groups'); + self::assertSame('groups', $groupsInputOption->getName()); + self::assertTrue($groupsInputOption->isArray()); + self::assertSame('User groups', $groupsInputOption->getDescription()); + self::assertSame([], $groupsInputOption->getDefault()); + + $rolesInputOption = $command->getDefinition()->getOption('roles'); + self::assertSame('roles', $rolesInputOption->getName()); + self::assertFalse($rolesInputOption->isValueRequired()); + self::assertTrue($rolesInputOption->isArray()); + self::assertSame(['ROLE_USER'], $rolesInputOption->getDefault()); + self::assertTrue($rolesInputOption->hasCompletion()); + $rolesInputOption->complete(new CompletionInput(), $suggestions = new CompletionSuggestions()); + self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions())); + } + + public function testInvalidArgumentType() + { + $command = new Command('foo'); + $command->setCode(function (#[Argument] object $any) {}); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The type "object" of parameter "$any" is not supported as a command argument. Only "string", "bool", "int", "float", "array" types are allowed.'); + + $command->getDefinition(); + } + + public function testInvalidOptionType() + { + $command = new Command('foo'); + $command->setCode(function (#[Option] object $any) {}); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The type "object" of parameter "$any" is not supported as a command option. Only "string", "bool", "int", "float", "array" types are allowed.'); + + $command->getDefinition(); + } + + public function getSuggestedRoles(CompletionInput $input): array + { + return ['ROLE_ADMIN', 'ROLE_USER']; + } +} diff --git a/src/Symfony/Component/Console/Tests/DependencyInjection/AddConsoleCommandPassTest.php b/src/Symfony/Component/Console/Tests/DependencyInjection/AddConsoleCommandPassTest.php index 639e5091ef22e..0df863720524a 100644 --- a/src/Symfony/Component/Console/Tests/DependencyInjection/AddConsoleCommandPassTest.php +++ b/src/Symfony/Component/Console/Tests/DependencyInjection/AddConsoleCommandPassTest.php @@ -206,7 +206,7 @@ public function testProcessThrowAnExceptionIfTheServiceIsNotASubclassOfCommand() $container->setDefinition('my-command', $definition); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The service "my-command" tagged "console.command" must be a subclass of "Symfony\Component\Console\Command\Command".'); + $this->expectExceptionMessage('The service "my-command" tagged "console.command" must either be a subclass of "Symfony\Component\Console\Command\Command" or have an "__invoke()" method'); $container->compile(); } @@ -303,6 +303,20 @@ public function testProcessOnChildDefinitionWithoutClass() $container->compile(); } + + public function testProcessInvokableCommand() + { + $container = new ContainerBuilder(); + $container->addCompilerPass(new AddConsoleCommandPass(), PassConfig::TYPE_BEFORE_REMOVING); + + $definition = new Definition(InvokableCommand::class); + $definition->addTag('console.command', ['command' => 'invokable', 'description' => 'Just testing']); + $container->setDefinition('invokable_command', $definition); + + $container->compile(); + + self::assertTrue($container->has('invokable_command.command')); + } } class MyCommand extends Command @@ -331,3 +345,11 @@ public function __construct() parent::__construct(); } } + +#[AsCommand(name: 'invokable', description: 'Just testing')] +class InvokableCommand +{ + public function __invoke(): void + { + } +} diff --git a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php index 42da50273b066..dbbf66e02ce10 100644 --- a/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php +++ b/src/Symfony/Component/Console/Tests/Helper/QuestionHelperTest.php @@ -777,7 +777,7 @@ public function testQuestionValidatorRepeatsThePrompt() $application = new Application(); $application->setAutoExit(false); $application->register('question') - ->setCode(function ($input, $output) use (&$tries) { + ->setCode(function (InputInterface $input, OutputInterface $output) use (&$tries) { $question = new Question('This is a promptable question'); $question->setValidator(function ($value) use (&$tries) { ++$tries; diff --git a/src/Symfony/Component/Console/Tests/Tester/ApplicationTesterTest.php b/src/Symfony/Component/Console/Tests/Tester/ApplicationTesterTest.php index f43775179f029..f990e94ccac00 100644 --- a/src/Symfony/Component/Console/Tests/Tester/ApplicationTesterTest.php +++ b/src/Symfony/Component/Console/Tests/Tester/ApplicationTesterTest.php @@ -14,7 +14,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\Output; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Tester\ApplicationTester; @@ -29,7 +31,7 @@ protected function setUp(): void $this->application->setAutoExit(false); $this->application->register('foo') ->addArgument('foo') - ->setCode(function ($input, $output) { + ->setCode(function (OutputInterface $output) { $output->writeln('foo'); }) ; @@ -65,7 +67,7 @@ public function testSetInputs() { $application = new Application(); $application->setAutoExit(false); - $application->register('foo')->setCode(function ($input, $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { $helper = new QuestionHelper(); $helper->ask($input, $output, new Question('Q1')); $helper->ask($input, $output, new Question('Q2')); @@ -91,7 +93,7 @@ public function testErrorOutput() $application->setAutoExit(false); $application->register('foo') ->addArgument('foo') - ->setCode(function ($input, $output) { + ->setCode(function (OutputInterface $output) { $output->getErrorOutput()->write('foo'); }) ; diff --git a/src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php b/src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php index ce0a24b99fda3..2e5329f8490f6 100644 --- a/src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php +++ b/src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php @@ -16,7 +16,9 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\Output; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; @@ -32,7 +34,7 @@ protected function setUp(): void $this->command = new Command('foo'); $this->command->addArgument('command'); $this->command->addArgument('foo'); - $this->command->setCode(function ($input, $output) { $output->writeln('foo'); }); + $this->command->setCode(function (OutputInterface $output) { $output->writeln('foo'); }); $this->tester = new CommandTester($this->command); $this->tester->execute(['foo' => 'bar'], ['interactive' => false, 'decorated' => false, 'verbosity' => Output::VERBOSITY_VERBOSE]); @@ -92,7 +94,7 @@ public function testCommandFromApplication() $application->setAutoExit(false); $command = new Command('foo'); - $command->setCode(function ($input, $output) { $output->writeln('foo'); }); + $command->setCode(function (OutputInterface $output) { $output->writeln('foo'); }); $application->add($command); @@ -112,7 +114,7 @@ public function testCommandWithInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function ($input, $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { $helper = $command->getHelper('question'); $helper->ask($input, $output, new Question($questions[0])); $helper->ask($input, $output, new Question($questions[1])); @@ -137,7 +139,7 @@ public function testCommandWithDefaultInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function ($input, $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { $helper = $command->getHelper('question'); $helper->ask($input, $output, new Question($questions[0], 'Bobby')); $helper->ask($input, $output, new Question($questions[1], 'Fine')); @@ -162,7 +164,7 @@ public function testCommandWithWrongInputsNumber() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function ($input, $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { $helper = $command->getHelper('question'); $helper->ask($input, $output, new ChoiceQuestion('choice', ['a', 'b'])); $helper->ask($input, $output, new Question($questions[0])); @@ -189,7 +191,7 @@ public function testCommandWithQuestionsButNoInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function ($input, $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { $helper = $command->getHelper('question'); $helper->ask($input, $output, new ChoiceQuestion('choice', ['a', 'b'])); $helper->ask($input, $output, new Question($questions[0])); @@ -214,7 +216,7 @@ public function testSymfonyStyleCommandWithInputs() ]; $command = new Command('foo'); - $command->setCode(function ($input, $output) use ($questions) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions) { $io = new SymfonyStyle($input, $output); $io->ask($questions[0]); $io->ask($questions[1]); @@ -233,7 +235,7 @@ public function testErrorOutput() $command = new Command('foo'); $command->addArgument('command'); $command->addArgument('foo'); - $command->setCode(function ($input, $output) { + $command->setCode(function (OutputInterface $output) { $output->getErrorOutput()->write('foo'); }); diff --git a/src/Symfony/Component/Console/composer.json b/src/Symfony/Component/Console/composer.json index 0ed1bd9af89b2..083036d5cf654 100644 --- a/src/Symfony/Component/Console/composer.json +++ b/src/Symfony/Component/Console/composer.json @@ -17,6 +17,7 @@ ], "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0"