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" pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy