diff --git a/Application.php b/Application.php index f0e0a303e..47be9f7c0 100644 --- a/Application.php +++ b/Application.php @@ -65,7 +65,7 @@ * Usage: * * $app = new Application('myapp', '1.0 (stable)'); - * $app->add(new SimpleCommand()); + * $app->addCommand(new SimpleCommand()); * $app->run(); * * @author Fabien Potencier @@ -512,7 +512,7 @@ public function getLongVersion(): string */ public function register(string $name): Command { - return $this->add(new Command($name)); + return $this->addCommand(new Command($name)); } /** @@ -520,25 +520,39 @@ public function register(string $name): Command * * If a Command is not enabled it will not be added. * - * @param Command[] $commands An array of commands + * @param callable[]|Command[] $commands An array of commands */ public function addCommands(array $commands): void { foreach ($commands as $command) { - $this->add($command); + $this->addCommand($command); } } + /** + * @deprecated since Symfony 7.4, use Application::addCommand() instead + */ + public function add(Command $command): ?Command + { + trigger_deprecation('symfony/console', '7.4', 'The "%s()" method is deprecated and will be removed in Symfony 8.0, use "%s::addCommand()" instead.', __METHOD__, self::class); + + return $this->addCommand($command); + } + /** * Adds a command object. * * If a command with the same name already exists, it will be overridden. * If the command is not enabled it will not be added. */ - public function add(Command $command): ?Command + public function addCommand(callable|Command $command): ?Command { $this->init(); + if (!$command instanceof Command) { + $command = new Command(null, $command); + } + $command->setApplication($this); if (!$command->isEnabled()) { @@ -604,7 +618,7 @@ public function has(string $name): bool { $this->init(); - return isset($this->commands[$name]) || ($this->commandLoader?->has($name) && $this->add($this->commandLoader->get($name))); + return isset($this->commands[$name]) || ($this->commandLoader?->has($name) && $this->addCommand($this->commandLoader->get($name))); } /** @@ -1321,8 +1335,14 @@ private function init(): void } $this->initialized = true; + if ((new \ReflectionMethod($this, 'add'))->getDeclaringClass()->getName() !== (new \ReflectionMethod($this, 'addCommand'))->getDeclaringClass()->getName()) { + $adder = $this->add(...); + } else { + $adder = $this->addCommand(...); + } + foreach ($this->getDefaultCommands() as $command) { - $this->add($command); + $adder($command); } } } diff --git a/Attribute/Argument.php b/Attribute/Argument.php index e6a94d2f1..203dcc2af 100644 --- a/Attribute/Argument.php +++ b/Attribute/Argument.php @@ -13,6 +13,7 @@ use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -27,6 +28,7 @@ class Argument private array|\Closure $suggestedValues; private ?int $mode = null; private string $function = ''; + private string $typeName = ''; /** * Represents a console command definition. @@ -66,20 +68,23 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name, $self->function)); } - $parameterTypeName = $type->getName(); + $self->typeName = $type->getName(); + $isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class); - if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) { - throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES))); + if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) { + throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types and backed enums are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES))); } if (!$self->name) { $self->name = (new UnicodeString($name))->kebab(); } - $self->default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + if ($parameter->isDefaultValueAvailable()) { + $self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue(); + } $self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; - if ('array' === $parameterTypeName) { + if ('array' === $self->typeName) { $self->mode |= InputArgument::IS_ARRAY; } @@ -87,6 +92,10 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self $self->suggestedValues = [$instance, $self->suggestedValues[1]]; } + if ($isBackedEnum && !$self->suggestedValues) { + $self->suggestedValues = array_column(($self->typeName)::cases(), 'value'); + } + return $self; } @@ -105,6 +114,12 @@ public function toInputArgument(): InputArgument */ public function resolveValue(InputInterface $input): mixed { - return $input->getArgument($this->name); + $value = $input->getArgument($this->name); + + if (is_subclass_of($this->typeName, \BackedEnum::class) && (\is_string($value) || \is_int($value))) { + return ($this->typeName)::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues); + } + + return $value; } } diff --git a/Attribute/AsCommand.php b/Attribute/AsCommand.php index 767d46ebb..02f156201 100644 --- a/Attribute/AsCommand.php +++ b/Attribute/AsCommand.php @@ -25,6 +25,7 @@ class AsCommand * @param string[] $aliases The list of aliases of the command. The command will be executed when using one of them (i.e. "cache:clean") * @param bool $hidden If true, the command won't be shown when listing all the available commands, but it can still be run as any other command * @param string|null $help The help content of the command, displayed with the help page + * @param string[] $usages The list of usage examples, displayed with the help page */ public function __construct( public string $name, @@ -32,6 +33,7 @@ public function __construct( array $aliases = [], bool $hidden = false, public ?string $help = null, + public array $usages = [], ) { if (!$hidden && !$aliases) { return; diff --git a/Attribute/Option.php b/Attribute/Option.php index 2f0256b17..6781e7dbd 100644 --- a/Attribute/Option.php +++ b/Attribute/Option.php @@ -13,6 +13,7 @@ use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; @@ -75,7 +76,7 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self $self->name = (new UnicodeString($name))->kebab(); } - $self->default = $parameter->getDefaultValue(); + $self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue(); $self->allowNull = $parameter->allowsNull(); if ($type instanceof \ReflectionUnionType) { @@ -87,9 +88,10 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self } $self->typeName = $type->getName(); + $isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class); - if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) { - throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES))); + if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) { + throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types and BackedEnum are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES))); } if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) { @@ -115,6 +117,10 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self $self->suggestedValues = [$instance, $self->suggestedValues[1]]; } + if ($isBackedEnum && !$self->suggestedValues) { + $self->suggestedValues = array_column(($self->typeName)::cases(), 'value'); + } + return $self; } @@ -140,6 +146,10 @@ public function resolveValue(InputInterface $input): mixed return true; } + if (is_subclass_of($this->typeName, \BackedEnum::class) && (\is_string($value) || \is_int($value))) { + return ($this->typeName)::tryFrom($value) ?? throw InvalidOptionException::fromEnumValue($this->name, $value, $this->suggestedValues); + } + if ('array' === $this->typeName && $this->allowNull && [] === $value) { return null; } @@ -158,7 +168,7 @@ public function resolveValue(InputInterface $input): mixed private function handleUnion(\ReflectionUnionType $type): self { $types = array_map( - static fn(\ReflectionType $t) => $t instanceof \ReflectionNamedType ? $t->getName() : null, + static fn (\ReflectionType $t) => $t instanceof \ReflectionNamedType ? $t->getName() : null, $type->getTypes(), ); diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f3ae3d7d..722045091 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,16 @@ CHANGELOG ========= +7.4 +--- + + * Allow setting aliases and the hidden flag via the command name passed to the constructor + * Introduce `Symfony\Component\Console\Application::addCommand()` to simplify using invokable commands when the component is used standalone + * Deprecate `Symfony\Component\Console\Application::add()` in favor of `Symfony\Component\Console\Application::addCommand()` + * Add `BackedEnum` support with `#[Argument]` and `#[Option]` inputs in invokable commands + * Allow Usages to be specified via `#[AsCommand]` attribute. + * Allow passing invokable commands to `Symfony\Component\Console\Tester\CommandTester` + 7.3 --- diff --git a/Command/Command.php b/Command/Command.php index f6cd84997..1d2e12bdc 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -87,23 +87,44 @@ public static function getDefaultDescription(): ?string * * @throws LogicException When the command name is empty */ - public function __construct(?string $name = null) + public function __construct(?string $name = null, ?callable $code = null) { $this->definition = new InputDefinition(); + if (null !== $code) { + if (!\is_object($code) || $code instanceof \Closure) { + throw new InvalidArgumentException(\sprintf('The command must be an instance of "%s" or an invokable object.', self::class)); + } + + /** @var AsCommand $attribute */ + $attribute = ((new \ReflectionObject($code))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance() + ?? throw new LogicException(\sprintf('The command must use the "%s" attribute.', AsCommand::class)); + + $this->setName($name ?? $attribute->name) + ->setDescription($attribute->description ?? '') + ->setHelp($attribute->help ?? '') + ->setCode($code); + + foreach ($attribute->usages as $usage) { + $this->addUsage($usage); + } + + return; + } + $attribute = ((new \ReflectionClass(static::class))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance(); if (null === $name) { if (self::class !== (new \ReflectionMethod($this, 'getDefaultName'))->class) { trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultName()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', static::class); - $defaultName = static::getDefaultName(); + $name = static::getDefaultName(); } else { - $defaultName = $attribute?->name; + $name = $attribute?->name; } } - if (null === $name && null !== $name = $defaultName) { + if (null !== $name) { $aliases = explode('|', $name); if ('' === $name = array_shift($aliases)) { @@ -134,7 +155,11 @@ public function __construct(?string $name = null) $this->setHelp($attribute?->help ?? ''); } - if (\is_callable($this) && (new \ReflectionMethod($this, 'execute'))->getDeclaringClass()->name === self::class) { + foreach ($attribute?->usages ?? [] as $usage) { + $this->addUsage($usage); + } + + if (\is_callable($this) && self::class === (new \ReflectionMethod($this, 'execute'))->getDeclaringClass()->name) { $this->code = new InvokableCommand($this, $this(...)); } diff --git a/Debug/CliRequest.php b/Debug/CliRequest.php index b023db07a..6e2c1012b 100644 --- a/Debug/CliRequest.php +++ b/Debug/CliRequest.php @@ -24,7 +24,7 @@ public function __construct( public readonly TraceableCommand $command, ) { parent::__construct( - attributes: ['_controller' => \get_class($command->command), '_virtual_type' => 'command'], + attributes: ['_controller' => $command->command::class, '_virtual_type' => 'command'], server: $_SERVER, ); } diff --git a/DependencyInjection/AddConsoleCommandPass.php b/DependencyInjection/AddConsoleCommandPass.php index 562627f4b..4a0ee4229 100644 --- a/DependencyInjection/AddConsoleCommandPass.php +++ b/DependencyInjection/AddConsoleCommandPass.php @@ -91,6 +91,7 @@ public function process(ContainerBuilder $container): void $description = $tags[0]['description'] ?? null; $help = $tags[0]['help'] ?? null; + $usages = $tags[0]['usages'] ?? null; unset($tags[0]); $lazyCommandMap[$commandName] = $id; @@ -108,6 +109,7 @@ public function process(ContainerBuilder $container): void $description ??= $tag['description'] ?? null; $help ??= $tag['help'] ?? null; + $usages ??= $tag['usages'] ?? null; } $definition->addMethodCall('setName', [$commandName]); @@ -124,6 +126,12 @@ public function process(ContainerBuilder $container): void $definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]); } + if ($usages) { + foreach ($usages as $usage) { + $definition->addMethodCall('addUsage', [$usage]); + } + } + if (!$description) { if (Command::class !== (new \ReflectionMethod($class, 'getDefaultDescription'))->class) { trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultDescription()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', $class); diff --git a/Exception/InvalidArgumentException.php b/Exception/InvalidArgumentException.php index 07cc0b61d..0482244f2 100644 --- a/Exception/InvalidArgumentException.php +++ b/Exception/InvalidArgumentException.php @@ -16,4 +16,17 @@ */ class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface { + /** + * @internal + */ + public static function fromEnumValue(string $name, string $value, array|\Closure $suggestedValues): self + { + $error = \sprintf('The value "%s" is not valid for the "%s" argument.', $value, $name); + + if (\is_array($suggestedValues)) { + $error .= \sprintf(' Supported values are "%s".', implode('", "', $suggestedValues)); + } + + return new self($error); + } } diff --git a/Exception/InvalidOptionException.php b/Exception/InvalidOptionException.php index 5cf62792e..e59167df1 100644 --- a/Exception/InvalidOptionException.php +++ b/Exception/InvalidOptionException.php @@ -18,4 +18,17 @@ */ class InvalidOptionException extends \InvalidArgumentException implements ExceptionInterface { + /** + * @internal + */ + public static function fromEnumValue(string $name, string $value, array|\Closure $suggestedValues): self + { + $error = \sprintf('The value "%s" is not valid for the "%s" option.', $value, $name); + + if (\is_array($suggestedValues)) { + $error .= \sprintf(' Supported values are "%s".', implode('", "', $suggestedValues)); + } + + return new self($error); + } } diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 8e1591ec1..9b65c3213 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -234,7 +234,8 @@ protected function writeError(OutputInterface $output, \Exception $error): void /** * Autocompletes a question. * - * @param resource $inputStream + * @param resource $inputStream + * @param callable(string):string[] $autocomplete */ private function autocomplete(OutputInterface $output, Question $question, $inputStream, callable $autocomplete): string { diff --git a/Question/Question.php b/Question/Question.php index 46a60c798..cb65bd674 100644 --- a/Question/Question.php +++ b/Question/Question.php @@ -24,8 +24,17 @@ class Question private ?int $attempts = null; private bool $hidden = false; private bool $hiddenFallback = true; + /** + * @var (\Closure(string):string[])|null + */ private ?\Closure $autocompleterCallback = null; + /** + * @var (\Closure(mixed):mixed)|null + */ private ?\Closure $validator = null; + /** + * @var (\Closure(mixed):mixed)|null + */ private ?\Closure $normalizer = null; private bool $trimmable = true; private bool $multiline = false; @@ -160,6 +169,8 @@ public function setAutocompleterValues(?iterable $values): static /** * Gets the callback function used for the autocompleter. + * + * @return (callable(string):string[])|null */ public function getAutocompleterCallback(): ?callable { @@ -171,6 +182,8 @@ public function getAutocompleterCallback(): ?callable * * The callback is passed the user input as argument and should return an iterable of corresponding suggestions. * + * @param (callable(string):string[])|null $callback + * * @return $this */ public function setAutocompleterCallback(?callable $callback): static @@ -187,6 +200,8 @@ public function setAutocompleterCallback(?callable $callback): static /** * Sets a validator for the question. * + * @param (callable(mixed):mixed)|null $validator + * * @return $this */ public function setValidator(?callable $validator): static @@ -198,6 +213,8 @@ public function setValidator(?callable $validator): static /** * Gets the validator for the question. + * + * @return (callable(mixed):mixed)|null */ public function getValidator(): ?callable { @@ -237,7 +254,7 @@ public function getMaxAttempts(): ?int /** * Sets a normalizer for the response. * - * The normalizer can be a callable (a string), a closure or a class implementing __invoke. + * @param callable(mixed):mixed $normalizer * * @return $this */ @@ -251,7 +268,7 @@ public function setNormalizer(callable $normalizer): static /** * Gets the normalizer for the response. * - * The normalizer can ba a callable (a string), a closure or a class implementing __invoke. + * @return (callable(mixed):mixed)|null */ public function getNormalizer(): ?callable { diff --git a/SingleCommandApplication.php b/SingleCommandApplication.php index 2b54fb870..837948d12 100644 --- a/SingleCommandApplication.php +++ b/SingleCommandApplication.php @@ -57,7 +57,7 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu $application->setAutoExit($this->autoExit); // Fix the usage of the command displayed with "--help" $this->setName($_SERVER['argv'][0]); - $application->add($this); + $application->addCommand($this); $application->setDefaultCommand($this->getName(), true); $this->running = true; diff --git a/Style/StyleInterface.php b/Style/StyleInterface.php index fcc5bc775..1a2232324 100644 --- a/Style/StyleInterface.php +++ b/Style/StyleInterface.php @@ -70,11 +70,15 @@ public function table(array $headers, array $rows): void; /** * Asks a question. + * + * @param (callable(mixed):mixed)|null $validator */ public function ask(string $question, ?string $default = null, ?callable $validator = null): mixed; /** * Asks a question with the user input hidden. + * + * @param (callable(mixed):mixed)|null $validator */ public function askHidden(string $question, ?callable $validator = null): mixed; diff --git a/Tester/CommandTester.php b/Tester/CommandTester.php index d39cde7f6..714d88ad5 100644 --- a/Tester/CommandTester.php +++ b/Tester/CommandTester.php @@ -24,9 +24,12 @@ class CommandTester { use TesterTrait; + private Command $command; + public function __construct( - private Command $command, + callable|Command $command, ) { + $this->command = $command instanceof Command ? $command : new Command(null, $command); } /** diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 268f8ba50..e9b45c051 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -16,6 +16,7 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\HelpCommand; +use Symfony\Component\Console\Command\InvokableCommand; use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; @@ -28,6 +29,8 @@ use Symfony\Component\Console\Event\ConsoleSignalEvent; use Symfony\Component\Console\Event\ConsoleTerminateEvent; use Symfony\Component\Console\Exception\CommandNotFoundException; +use Symfony\Component\Console\Exception\InvalidArgumentException; +use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Exception\NamespaceNotFoundException; use Symfony\Component\Console\Helper\FormatterHelper; use Symfony\Component\Console\Helper\HelperSet; @@ -45,6 +48,8 @@ use Symfony\Component\Console\SignalRegistry\SignalRegistry; use Symfony\Component\Console\Terminal; use Symfony\Component\Console\Tester\ApplicationTester; +use Symfony\Component\Console\Tests\Fixtures\InvokableExtendingCommandTestCommand; +use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand; use Symfony\Component\Console\Tests\Fixtures\MockableAppliationWithTerminalWidth; use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\EventDispatcher\EventDispatcher; @@ -163,7 +168,7 @@ public function testAll() $commands = $application->all(); $this->assertInstanceOf(HelpCommand::class, $commands['help'], '->all() returns the registered commands'); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $commands = $application->all('foo'); $this->assertCount(1, $commands, '->all() takes a namespace as its first argument'); } @@ -174,7 +179,7 @@ public function testAllWithCommandLoader() $commands = $application->all(); $this->assertInstanceOf(HelpCommand::class, $commands['help'], '->all() returns the registered commands'); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $commands = $application->all('foo'); $this->assertCount(1, $commands, '->all() takes a namespace as its first argument'); @@ -221,7 +226,7 @@ public function testRegisterAmbiguous() public function testAdd() { $application = new Application(); - $application->add($foo = new \FooCommand()); + $application->addCommand($foo = new \FooCommand()); $commands = $application->all(); $this->assertEquals($foo, $commands['foo:bar'], '->add() registers a command'); @@ -236,7 +241,60 @@ public function testAddCommandWithEmptyConstructor() $this->expectException(\LogicException::class); $this->expectExceptionMessage('Command class "Foo5Command" is not correctly initialized. You probably forgot to call the parent constructor.'); - (new Application())->add(new \Foo5Command()); + (new Application())->addCommand(new \Foo5Command()); + } + + public function testAddCommandWithExtendedCommand() + { + $application = new Application(); + $application->addCommand($foo = new \FooCommand()); + $commands = $application->all(); + + $this->assertEquals($foo, $commands['foo:bar']); + } + + public function testAddCommandWithInvokableCommand() + { + $application = new Application(); + $application->addCommand($foo = new InvokableTestCommand()); + $commands = $application->all(); + + $this->assertInstanceOf(Command::class, $command = $commands['invokable:test']); + $this->assertEquals(new InvokableCommand($command, $foo), (new \ReflectionObject($command))->getProperty('code')->getValue($command)); + } + + public function testAddCommandWithInvokableExtendedCommand() + { + $application = new Application(); + $application->addCommand($foo = new InvokableExtendingCommandTestCommand()); + $commands = $application->all(); + + $this->assertEquals($foo, $commands['invokable:test']); + } + + /** + * @dataProvider provideInvalidInvokableCommands + */ + public function testAddCommandThrowsExceptionOnInvalidCommand(callable $command, string $expectedException, string $expectedExceptionMessage) + { + $application = new Application(); + + $this->expectException($expectedException); + $this->expectExceptionMessage($expectedExceptionMessage); + + $application->addCommand($command); + } + + public static function provideInvalidInvokableCommands(): iterable + { + yield 'a function' => ['strlen', InvalidArgumentException::class, \sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)]; + yield 'a closure' => [function () { + }, InvalidArgumentException::class, \sprintf('The command must be an instance of "%s" or an invokable object.', Command::class)]; + yield 'without the #[AsCommand] attribute' => [new class { + public function __invoke() + { + } + }, LogicException::class, \sprintf('The command must use the "%s" attribute.', AsCommand::class)]; } public function testHasGet() @@ -245,13 +303,13 @@ public function testHasGet() $this->assertTrue($application->has('list'), '->has() returns true if a named command is registered'); $this->assertFalse($application->has('afoobar'), '->has() returns false if a named command is not registered'); - $application->add($foo = new \FooCommand()); + $application->addCommand($foo = new \FooCommand()); $this->assertTrue($application->has('afoobar'), '->has() returns true if an alias is registered'); $this->assertEquals($foo, $application->get('foo:bar'), '->get() returns a command by name'); $this->assertEquals($foo, $application->get('afoobar'), '->get() returns a command by alias'); $application = new Application(); - $application->add($foo = new \FooCommand()); + $application->addCommand($foo = new \FooCommand()); // simulate --help $r = new \ReflectionObject($application); $p = $r->getProperty('wantHelps'); @@ -266,7 +324,7 @@ public function testHasGetWithCommandLoader() $this->assertTrue($application->has('list'), '->has() returns true if a named command is registered'); $this->assertFalse($application->has('afoobar'), '->has() returns false if a named command is not registered'); - $application->add($foo = new \FooCommand()); + $application->addCommand($foo = new \FooCommand()); $this->assertTrue($application->has('afoobar'), '->has() returns true if an alias is registered'); $this->assertEquals($foo, $application->get('foo:bar'), '->get() returns a command by name'); $this->assertEquals($foo, $application->get('afoobar'), '->get() returns a command by alias'); @@ -307,35 +365,35 @@ public function testGetInvalidCommand() public function testGetNamespaces() { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \Foo1Command()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo1Command()); $this->assertEquals(['foo'], $application->getNamespaces(), '->getNamespaces() returns an array of unique used namespaces'); } public function testFindNamespace() { $application = new Application(); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $this->assertEquals('foo', $application->findNamespace('foo'), '->findNamespace() returns the given namespace if it exists'); $this->assertEquals('foo', $application->findNamespace('f'), '->findNamespace() finds a namespace given an abbreviation'); - $application->add(new \Foo2Command()); + $application->addCommand(new \Foo2Command()); $this->assertEquals('foo', $application->findNamespace('foo'), '->findNamespace() returns the given namespace if it exists'); } public function testFindNamespaceWithSubnamespaces() { $application = new Application(); - $application->add(new \FooSubnamespaced1Command()); - $application->add(new \FooSubnamespaced2Command()); + $application->addCommand(new \FooSubnamespaced1Command()); + $application->addCommand(new \FooSubnamespaced2Command()); $this->assertEquals('foo', $application->findNamespace('foo'), '->findNamespace() returns commands even if the commands are only contained in subnamespaces'); } public function testFindAmbiguousNamespace() { $application = new Application(); - $application->add(new \BarBucCommand()); - $application->add(new \FooCommand()); - $application->add(new \Foo2Command()); + $application->addCommand(new \BarBucCommand()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo2Command()); $expectedMsg = "The namespace \"f\" is ambiguous.\nDid you mean one of these?\n foo\n foo1"; @@ -348,8 +406,8 @@ public function testFindAmbiguousNamespace() public function testFindNonAmbiguous() { $application = new Application(); - $application->add(new \TestAmbiguousCommandRegistering()); - $application->add(new \TestAmbiguousCommandRegistering2()); + $application->addCommand(new \TestAmbiguousCommandRegistering()); + $application->addCommand(new \TestAmbiguousCommandRegistering2()); $this->assertEquals('test-ambiguous', $application->find('test')->getName()); } @@ -364,9 +422,9 @@ public function testFindInvalidNamespace() public function testFindUniqueNameButNamespaceName() { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \Foo1Command()); - $application->add(new \Foo2Command()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo1Command()); + $application->addCommand(new \Foo2Command()); $this->expectException(CommandNotFoundException::class); $this->expectExceptionMessage('Command "foo1" is not defined'); @@ -377,7 +435,7 @@ public function testFindUniqueNameButNamespaceName() public function testFind() { $application = new Application(); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $this->assertInstanceOf(\FooCommand::class, $application->find('foo:bar'), '->find() returns a command if its name exists'); $this->assertInstanceOf(HelpCommand::class, $application->find('h'), '->find() returns a command if its name exists'); @@ -389,8 +447,8 @@ public function testFind() public function testFindCaseSensitiveFirst() { $application = new Application(); - $application->add(new \FooSameCaseUppercaseCommand()); - $application->add(new \FooSameCaseLowercaseCommand()); + $application->addCommand(new \FooSameCaseUppercaseCommand()); + $application->addCommand(new \FooSameCaseLowercaseCommand()); $this->assertInstanceOf(\FooSameCaseUppercaseCommand::class, $application->find('f:B'), '->find() returns a command if the abbreviation is the correct case'); $this->assertInstanceOf(\FooSameCaseUppercaseCommand::class, $application->find('f:BAR'), '->find() returns a command if the abbreviation is the correct case'); @@ -401,7 +459,7 @@ public function testFindCaseSensitiveFirst() public function testFindCaseInsensitiveAsFallback() { $application = new Application(); - $application->add(new \FooSameCaseLowercaseCommand()); + $application->addCommand(new \FooSameCaseLowercaseCommand()); $this->assertInstanceOf(\FooSameCaseLowercaseCommand::class, $application->find('f:b'), '->find() returns a command if the abbreviation is the correct case'); $this->assertInstanceOf(\FooSameCaseLowercaseCommand::class, $application->find('f:B'), '->find() will fallback to case insensitivity'); @@ -411,8 +469,8 @@ public function testFindCaseInsensitiveAsFallback() public function testFindCaseInsensitiveSuggestions() { $application = new Application(); - $application->add(new \FooSameCaseLowercaseCommand()); - $application->add(new \FooSameCaseUppercaseCommand()); + $application->addCommand(new \FooSameCaseLowercaseCommand()); + $application->addCommand(new \FooSameCaseUppercaseCommand()); $this->expectException(CommandNotFoundException::class); $this->expectExceptionMessage('Command "FoO:BaR" is ambiguous'); @@ -444,9 +502,9 @@ public function testFindWithAmbiguousAbbreviations($abbreviation, $expectedExcep $this->expectExceptionMessage($expectedExceptionMessage); $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \Foo1Command()); - $application->add(new \Foo2Command()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo1Command()); + $application->addCommand(new \Foo2Command()); $application->find($abbreviation); } @@ -476,8 +534,8 @@ public function testFindWithAmbiguousAbbreviationsFindsCommandIfAlternativesAreH { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \FooHiddenCommand()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \FooHiddenCommand()); $this->assertInstanceOf(\FooCommand::class, $application->find('foo:')); } @@ -485,8 +543,8 @@ public function testFindWithAmbiguousAbbreviationsFindsCommandIfAlternativesAreH public function testFindCommandEqualNamespace() { $application = new Application(); - $application->add(new \Foo3Command()); - $application->add(new \Foo4Command()); + $application->addCommand(new \Foo3Command()); + $application->addCommand(new \Foo4Command()); $this->assertInstanceOf(\Foo3Command::class, $application->find('foo3:bar'), '->find() returns the good command even if a namespace has same name'); $this->assertInstanceOf(\Foo4Command::class, $application->find('foo3:bar:toh'), '->find() returns a command even if its namespace equals another command name'); @@ -495,8 +553,8 @@ public function testFindCommandEqualNamespace() public function testFindCommandWithAmbiguousNamespacesButUniqueName() { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \FoobarCommand()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \FoobarCommand()); $this->assertInstanceOf(\FoobarCommand::class, $application->find('f:f')); } @@ -504,7 +562,7 @@ public function testFindCommandWithAmbiguousNamespacesButUniqueName() public function testFindCommandWithMissingNamespace() { $application = new Application(); - $application->add(new \Foo4Command()); + $application->addCommand(new \Foo4Command()); $this->assertInstanceOf(\Foo4Command::class, $application->find('f::t')); } @@ -515,7 +573,7 @@ public function testFindCommandWithMissingNamespace() public function testFindAlternativeExceptionMessageSingle($name) { $application = new Application(); - $application->add(new \Foo3Command()); + $application->addCommand(new \Foo3Command()); $this->expectException(CommandNotFoundException::class); $this->expectExceptionMessage('Did you mean this'); @@ -526,7 +584,7 @@ public function testFindAlternativeExceptionMessageSingle($name) public function testDontRunAlternativeNamespaceName() { $application = new Application(); - $application->add(new \Foo1Command()); + $application->addCommand(new \Foo1Command()); $application->setAutoExit(false); $tester = new ApplicationTester($application); $tester->run(['command' => 'foos:bar1'], ['decorated' => false]); @@ -536,7 +594,7 @@ public function testDontRunAlternativeNamespaceName() public function testCanRunAlternativeCommandName() { $application = new Application(); - $application->add(new \FooWithoutAliasCommand()); + $application->addCommand(new \FooWithoutAliasCommand()); $application->setAutoExit(false); $tester = new ApplicationTester($application); $tester->setInputs(['y']); @@ -550,7 +608,7 @@ public function testCanRunAlternativeCommandName() public function testDontRunAlternativeCommandName() { $application = new Application(); - $application->add(new \FooWithoutAliasCommand()); + $application->addCommand(new \FooWithoutAliasCommand()); $application->setAutoExit(false); $tester = new ApplicationTester($application); $tester->setInputs(['n']); @@ -574,9 +632,9 @@ public function testRunNamespace() putenv('COLUMNS=120'); $application = new Application(); $application->setAutoExit(false); - $application->add(new \FooCommand()); - $application->add(new \Foo1Command()); - $application->add(new \Foo2Command()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo1Command()); + $application->addCommand(new \Foo2Command()); $tester = new ApplicationTester($application); $tester->run(['command' => 'foo'], ['decorated' => false]); $display = trim($tester->getDisplay(true)); @@ -589,9 +647,9 @@ public function testFindAlternativeExceptionMessageMultiple() { putenv('COLUMNS=120'); $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \Foo1Command()); - $application->add(new \Foo2Command()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo1Command()); + $application->addCommand(new \Foo2Command()); // Command + plural try { @@ -614,8 +672,8 @@ public function testFindAlternativeExceptionMessageMultiple() $this->assertMatchesRegularExpression('/foo1/', $e->getMessage()); } - $application->add(new \Foo3Command()); - $application->add(new \Foo4Command()); + $application->addCommand(new \Foo3Command()); + $application->addCommand(new \Foo4Command()); // Subnamespace + plural try { @@ -632,9 +690,9 @@ public function testFindAlternativeCommands() { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \Foo1Command()); - $application->add(new \Foo2Command()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo1Command()); + $application->addCommand(new \Foo2Command()); try { $application->find($commandName = 'Unknown command'); @@ -669,7 +727,7 @@ public function testFindAlternativeCommandsWithAnAlias() $application->setCommandLoader(new FactoryCommandLoader([ 'foo3' => static fn () => $fooCommand, ])); - $application->add($fooCommand); + $application->addCommand($fooCommand); $result = $application->find('foo'); @@ -680,10 +738,10 @@ public function testFindAlternativeNamespace() { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \Foo1Command()); - $application->add(new \Foo2Command()); - $application->add(new \Foo3Command()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo1Command()); + $application->addCommand(new \Foo2Command()); + $application->addCommand(new \Foo3Command()); try { $application->find('Unknown-namespace:Unknown-command'); @@ -715,11 +773,11 @@ public function testFindAlternativesOutput() { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \Foo1Command()); - $application->add(new \Foo2Command()); - $application->add(new \Foo3Command()); - $application->add(new \FooHiddenCommand()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo1Command()); + $application->addCommand(new \Foo2Command()); + $application->addCommand(new \Foo3Command()); + $application->addCommand(new \FooHiddenCommand()); $expectedAlternatives = [ 'afoobar', @@ -755,8 +813,8 @@ public function testFindNamespaceDoesNotFailOnDeepSimilarNamespaces() public function testFindWithDoubleColonInNameThrowsException() { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \Foo4Command()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \Foo4Command()); $this->expectException(CommandNotFoundException::class); $this->expectExceptionMessage('Command "foo::bar" is not defined.'); @@ -767,7 +825,7 @@ public function testFindWithDoubleColonInNameThrowsException() public function testFindHiddenWithExactName() { $application = new Application(); - $application->add(new \FooHiddenCommand()); + $application->addCommand(new \FooHiddenCommand()); $this->assertInstanceOf(\FooHiddenCommand::class, $application->find('foo:hidden')); $this->assertInstanceOf(\FooHiddenCommand::class, $application->find('afoohidden')); @@ -777,8 +835,8 @@ public function testFindAmbiguousCommandsIfAllAlternativesAreHidden() { $application = new Application(); - $application->add(new \FooCommand()); - $application->add(new \FooHiddenCommand()); + $application->addCommand(new \FooCommand()); + $application->addCommand(new \FooHiddenCommand()); $this->assertInstanceOf(\FooCommand::class, $application->find('foo:')); } @@ -824,7 +882,7 @@ public function testSetCatchErrors(bool $catchExceptions) $application = new Application(); $application->setAutoExit(false); $application->setCatchExceptions($catchExceptions); - $application->add((new Command('boom'))->setCode(fn () => throw new \Error('This is an error.'))); + $application->addCommand((new Command('boom'))->setCode(fn () => throw new \Error('This is an error.'))); putenv('COLUMNS=120'); $tester = new ApplicationTester($application); @@ -870,7 +928,7 @@ public function testRenderException() $tester->run(['command' => 'list', '--foo' => true], ['decorated' => false, 'capture_stderr_separately' => true]); $this->assertStringEqualsFile(self::$fixturesPath.'/application_renderexception2.txt', $tester->getErrorOutput(true), '->renderException() renders the command synopsis when an exception occurs in the context of a command'); - $application->add(new \Foo3Command()); + $application->addCommand(new \Foo3Command()); $tester = new ApplicationTester($application); $tester->run(['command' => 'foo3:bar'], ['decorated' => false, 'capture_stderr_separately' => true]); $this->assertStringEqualsFile(self::$fixturesPath.'/application_renderexception3.txt', $tester->getErrorOutput(true), '->renderException() renders a pretty exceptions with previous exceptions'); @@ -1031,7 +1089,7 @@ public function testRun() $application = new Application(); $application->setAutoExit(false); $application->setCatchExceptions(false); - $application->add($command = new \Foo1Command()); + $application->addCommand($command = new \Foo1Command()); $_SERVER['argv'] = ['cli.php', 'foo:bar1']; ob_start(); @@ -1116,7 +1174,7 @@ public function testRun() $application = new Application(); $application->setAutoExit(false); $application->setCatchExceptions(false); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $tester = new ApplicationTester($application); $tester->run(['command' => 'foo:bar', '--no-interaction' => true], ['decorated' => false]); @@ -1151,7 +1209,7 @@ public function testVerboseValueNotBreakArguments() $application = new Application(); $application->setAutoExit(false); $application->setCatchExceptions(false); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $output = new StreamOutput(fopen('php://memory', 'w', false)); @@ -1762,7 +1820,7 @@ public function testSetRunCustomDefaultCommand() $application = new Application(); $application->setAutoExit(false); - $application->add($command); + $application->addCommand($command); $application->setDefaultCommand($command->getName()); $tester = new ApplicationTester($application); @@ -1784,7 +1842,7 @@ public function testSetRunCustomDefaultCommandWithOption() $application = new Application(); $application->setAutoExit(false); - $application->add($command); + $application->addCommand($command); $application->setDefaultCommand($command->getName()); $tester = new ApplicationTester($application); @@ -1799,7 +1857,7 @@ public function testSetRunCustomSingleCommand() $application = new Application(); $application->setAutoExit(false); - $application->add($command); + $application->addCommand($command); $application->setDefaultCommand($command->getName(), true); $tester = new ApplicationTester($application); @@ -2150,7 +2208,7 @@ public function testSignalableCommandInterfaceWithoutSignals() $application = new Application(); $application->setAutoExit(false); $application->setDispatcher($dispatcher); - $application->add($command); + $application->addCommand($command); $this->assertSame(0, $application->run(new ArrayInput(['signal']))); } @@ -2186,7 +2244,7 @@ public function testSignalableCommandDoesNotInterruptedOnTermSignals() $application = new Application(); $application->setAutoExit(false); $application->setDispatcher($dispatcher); - $application->add($command); + $application->addCommand($command); $this->assertSame(129, $application->run(new ArrayInput(['signal']))); } @@ -2208,7 +2266,7 @@ public function testSignalableWithEventCommandDoesNotInterruptedOnTermSignals() $application = new Application(); $application->setAutoExit(false); $application->setDispatcher($dispatcher); - $application->add($command); + $application->addCommand($command); $tester = new ApplicationTester($application); $this->assertSame(51, $tester->run(['signal'])); $expected = <<setAutoExit(false); $application->setDispatcher($dispatcher); - $application->add($command); + $application->addCommand($command); $this->assertSame(0, $application->run(new ArrayInput(['alarm']))); $this->assertFalse($command->signaled); @@ -2459,7 +2517,7 @@ private function createSignalableApplication(Command $command, ?EventDispatcherI if ($dispatcher) { $application->setDispatcher($dispatcher); } - $application->add(new LazyCommand($command->getName(), [], '', false, fn () => $command, true)); + $application->addCommand(new LazyCommand($command->getName(), [], '', false, fn () => $command, true)); return $application; } @@ -2491,7 +2549,7 @@ public function __construct() parent::__construct(); $command = new \FooCommand(); - $this->add($command); + $this->addCommand($command); $this->setDefaultCommand($command->getName()); } } diff --git a/Tests/Command/CommandTest.php b/Tests/Command/CommandTest.php index 0db3572fc..a4a719b3d 100644 --- a/Tests/Command/CommandTest.php +++ b/Tests/Command/CommandTest.php @@ -27,6 +27,7 @@ use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand; class CommandTest extends TestCase { @@ -50,7 +51,7 @@ public function testCommandNameCannotBeEmpty() { $this->expectException(\LogicException::class); $this->expectExceptionMessage('The command defined in "Symfony\Component\Console\Command\Command" cannot have an empty name.'); - (new Application())->add(new Command()); + (new Application())->addCommand(new Command()); } public function testSetApplication() @@ -190,7 +191,7 @@ public function testGetProcessedHelp() $command = new \TestCommand(); $command->setHelp('The %command.name% command does... Example: %command.full_name%.'); $application = new Application(); - $application->add($command); + $application->addCommand($command); $application->setDefaultCommand('namespace:name', true); $this->assertStringContainsString('The namespace:name command does...', $command->getProcessedHelp(), '->getProcessedHelp() replaces %command.name% correctly in single command applications'); $this->assertStringNotContainsString('%command.full_name%', $command->getProcessedHelp(), '->getProcessedHelp() replaces %command.full_name% in single command applications'); @@ -205,6 +206,19 @@ public function testGetSetAliases() $this->assertEquals(['name1'], $command->getAliases(), '->setAliases() sets the aliases'); } + /** + * @testWith ["name|alias1|alias2", "name", ["alias1", "alias2"], false] + * ["|alias1|alias2", "alias1", ["alias2"], true] + */ + public function testSetAliasesAndHiddenViaName(string $name, string $expectedName, array $expectedAliases, bool $expectedHidden) + { + $command = new Command($name); + + self::assertSame($expectedName, $command->getName()); + self::assertSame($expectedHidden, $command->isHidden()); + self::assertSame($expectedAliases, $command->getAliases()); + } + public function testGetSynopsis() { $command = new \TestCommand(); @@ -291,6 +305,13 @@ public function testRunInteractive() $this->assertEquals('interact called'.\PHP_EOL.'execute called'.\PHP_EOL, $tester->getDisplay(), '->run() calls the interact() method if the input is interactive'); } + public function testInvokableCommand() + { + $tester = new CommandTester(new InvokableTestCommand()); + + $this->assertSame(Command::SUCCESS, $tester->execute([])); + } + public function testRunNonInteractive() { $tester = new CommandTester(new \TestCommand()); @@ -444,6 +465,8 @@ public function testCommandAttribute() $this->assertSame('foo', $command->getName()); $this->assertSame('desc', $command->getDescription()); $this->assertSame('help', $command->getHelp()); + $this->assertCount(2, $command->getUsages()); + $this->assertStringContainsString('usage1', $command->getUsages()[0]); $this->assertTrue($command->isHidden()); $this->assertSame(['f'], $command->getAliases()); } @@ -529,7 +552,7 @@ function createClosure() }; } -#[AsCommand(name: 'foo', description: 'desc', hidden: true, aliases: ['f'], help: 'help')] +#[AsCommand(name: 'foo', description: 'desc', usages: ['usage1', 'usage2'], hidden: true, aliases: ['f'], help: 'help')] class Php8Command extends Command { } diff --git a/Tests/Command/CompleteCommandTest.php b/Tests/Command/CompleteCommandTest.php index 75519eb49..08f6b046f 100644 --- a/Tests/Command/CompleteCommandTest.php +++ b/Tests/Command/CompleteCommandTest.php @@ -33,7 +33,7 @@ protected function setUp(): void $this->command = new CompleteCommand(); $this->application = new Application(); - $this->application->add(new CompleteCommandTest_HelloCommand()); + $this->application->addCommand(new CompleteCommandTest_HelloCommand()); $this->command->setApplication($this->application); $this->tester = new CommandTester($this->command); diff --git a/Tests/Command/HelpCommandTest.php b/Tests/Command/HelpCommandTest.php index c36ab62df..f1979c0dc 100644 --- a/Tests/Command/HelpCommandTest.php +++ b/Tests/Command/HelpCommandTest.php @@ -77,7 +77,7 @@ public function testComplete(array $input, array $expectedSuggestions) { require_once realpath(__DIR__.'/../Fixtures/FooCommand.php'); $application = new Application(); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $tester = new CommandCompletionTester($application->get('help')); $suggestions = $tester->complete($input, 2); $this->assertSame($expectedSuggestions, $suggestions); diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index 5ab7951e7..8bd0dceb4 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Tests\Command; +use PHPUnit\Framework\Assert; use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Attribute\Argument; use Symfony\Component\Console\Attribute\Option; @@ -18,6 +19,7 @@ use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Input\ArrayInput; @@ -132,6 +134,88 @@ public function testCommandInputOptionDefinition() self::assertFalse($optInputOption->getDefault()); } + public function testEnumArgument() + { + $command = new Command('foo'); + $command->setCode(function ( + #[Argument] StringEnum $enum, + #[Argument] StringEnum $enumWithDefault = StringEnum::Image, + #[Argument] ?StringEnum $nullableEnum = null, + ): int { + Assert::assertSame(StringEnum::Image, $enum); + Assert::assertSame(StringEnum::Image, $enumWithDefault); + Assert::assertNull($nullableEnum); + + return 0; + }); + + $enumInputArgument = $command->getDefinition()->getArgument('enum'); + self::assertTrue($enumInputArgument->isRequired()); + self::assertNull($enumInputArgument->getDefault()); + self::assertTrue($enumInputArgument->hasCompletion()); + + $enumWithDefaultInputArgument = $command->getDefinition()->getArgument('enum-with-default'); + self::assertFalse($enumWithDefaultInputArgument->isRequired()); + self::assertSame('image', $enumWithDefaultInputArgument->getDefault()); + self::assertTrue($enumWithDefaultInputArgument->hasCompletion()); + + $nullableEnumInputArgument = $command->getDefinition()->getArgument('nullable-enum'); + self::assertFalse($nullableEnumInputArgument->isRequired()); + self::assertNull($nullableEnumInputArgument->getDefault()); + self::assertTrue($nullableEnumInputArgument->hasCompletion()); + + $enumInputArgument->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions()); + self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions()); + + $command->run(new ArrayInput(['enum' => 'image']), new NullOutput()); + + self::expectException(InvalidArgumentException::class); + self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" argument. Supported values are "image", "video".'); + + $command->run(new ArrayInput(['enum' => 'incorrect']), new NullOutput()); + } + + public function testEnumOption() + { + $command = new Command('foo'); + $command->setCode(function ( + #[Option] StringEnum $enum = StringEnum::Video, + #[Option] StringEnum $enumWithDefault = StringEnum::Image, + #[Option] ?StringEnum $nullableEnum = null, + ): int { + Assert::assertSame(StringEnum::Image, $enum); + Assert::assertSame(StringEnum::Image, $enumWithDefault); + Assert::assertNull($nullableEnum); + + return 0; + }); + + $enumInputOption = $command->getDefinition()->getOption('enum'); + self::assertTrue($enumInputOption->isValueRequired()); + self::assertSame('video', $enumInputOption->getDefault()); + self::assertTrue($enumInputOption->hasCompletion()); + + $enumWithDefaultInputOption = $command->getDefinition()->getOption('enum-with-default'); + self::assertTrue($enumWithDefaultInputOption->isValueRequired()); + self::assertSame('image', $enumWithDefaultInputOption->getDefault()); + self::assertTrue($enumWithDefaultInputOption->hasCompletion()); + + $nullableEnumInputOption = $command->getDefinition()->getOption('nullable-enum'); + self::assertTrue($nullableEnumInputOption->isValueRequired()); + self::assertNull($nullableEnumInputOption->getDefault()); + self::assertTrue($nullableEnumInputOption->hasCompletion()); + + $enumInputOption->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions()); + self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions()); + + $command->run(new ArrayInput(['--enum' => 'image']), new NullOutput()); + + self::expectException(InvalidOptionException::class); + self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" option. Supported values are "image", "video".'); + + $command->run(new ArrayInput(['--enum' => 'incorrect']), new NullOutput()); + } + public function testInvalidArgumentType() { $command = new Command('foo'); @@ -156,6 +240,7 @@ public function testExecuteHasPriorityOverInvokeMethod() { $command = new class extends Command { public string $called; + protected function execute(InputInterface $input, OutputInterface $output): int { $this->called = __FUNCTION__; @@ -179,6 +264,7 @@ public function testCallInvokeMethodWhenExtendingCommandClass() { $command = new class extends Command { public string $called; + public function __invoke(): int { $this->called = __FUNCTION__; @@ -195,7 +281,9 @@ public function testInvalidReturnType() { $command = new Command('foo'); $command->setCode(new class { - public function __invoke() {} + public function __invoke() + { + } }); $this->expectException(\TypeError::class); @@ -333,16 +421,16 @@ public function testInvalidOptionDefinition(callable $code) public static function provideInvalidOptionDefinitions(): \Generator { yield 'no-default' => [ - function (#[Option] string $a) {} + function (#[Option] string $a) {}, ]; yield 'nullable-bool-default-true' => [ - function (#[Option] ?bool $a = true) {} + function (#[Option] ?bool $a = true) {}, ]; yield 'nullable-bool-default-false' => [ - function (#[Option] ?bool $a = false) {} + function (#[Option] ?bool $a = false) {}, ]; yield 'invalid-union-type' => [ - function (#[Option] array|bool $a = false) {} + function (#[Option] array|bool $a = false) {}, ]; yield 'union-type-cannot-allow-null' => [ function (#[Option] string|bool|null $a = null) {}, @@ -377,3 +465,9 @@ public function getSuggestedRoles(CompletionInput $input): array return ['ROLE_ADMIN', 'ROLE_USER']; } } + +enum StringEnum: string +{ + case Image = 'image'; + case Video = 'video'; +} diff --git a/Tests/Command/ListCommandTest.php b/Tests/Command/ListCommandTest.php index a6ffc8ab5..37496c6b3 100644 --- a/Tests/Command/ListCommandTest.php +++ b/Tests/Command/ListCommandTest.php @@ -54,7 +54,7 @@ public function testExecuteListsCommandsWithNamespaceArgument() { require_once realpath(__DIR__.'/../Fixtures/FooCommand.php'); $application = new Application(); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $commandTester = new CommandTester($command = $application->get('list')); $commandTester->execute(['command' => $command->getName(), 'namespace' => 'foo', '--raw' => true]); $output = <<<'EOF' @@ -69,7 +69,7 @@ public function testExecuteListsCommandsOrder() { require_once realpath(__DIR__.'/../Fixtures/Foo6Command.php'); $application = new Application(); - $application->add(new \Foo6Command()); + $application->addCommand(new \Foo6Command()); $commandTester = new CommandTester($command = $application->get('list')); $commandTester->execute(['command' => $command->getName()], ['decorated' => false]); $output = <<<'EOF' @@ -102,7 +102,7 @@ public function testExecuteListsCommandsOrderRaw() { require_once realpath(__DIR__.'/../Fixtures/Foo6Command.php'); $application = new Application(); - $application->add(new \Foo6Command()); + $application->addCommand(new \Foo6Command()); $commandTester = new CommandTester($command = $application->get('list')); $commandTester->execute(['command' => $command->getName(), '--raw' => true]); $output = <<<'EOF' @@ -122,7 +122,7 @@ public function testComplete(array $input, array $expectedSuggestions) { require_once realpath(__DIR__.'/../Fixtures/FooCommand.php'); $application = new Application(); - $application->add(new \FooCommand()); + $application->addCommand(new \FooCommand()); $tester = new CommandCompletionTester($application->get('list')); $suggestions = $tester->complete($input, 2); $this->assertSame($expectedSuggestions, $suggestions); diff --git a/Tests/ConsoleEventsTest.php b/Tests/ConsoleEventsTest.php index 408f8c0d3..3421eda80 100644 --- a/Tests/ConsoleEventsTest.php +++ b/Tests/ConsoleEventsTest.php @@ -58,7 +58,7 @@ public function testEventAliases() ->setPublic(true) ->addMethodCall('setAutoExit', [false]) ->addMethodCall('setDispatcher', [new Reference('event_dispatcher')]) - ->addMethodCall('add', [new Reference('failing_command')]) + ->addMethodCall('addCommand', [new Reference('failing_command')]) ; $container->compile(); diff --git a/Tests/DependencyInjection/AddConsoleCommandPassTest.php b/Tests/DependencyInjection/AddConsoleCommandPassTest.php index 9ac660100..a11e6b510 100644 --- a/Tests/DependencyInjection/AddConsoleCommandPassTest.php +++ b/Tests/DependencyInjection/AddConsoleCommandPassTest.php @@ -315,6 +315,7 @@ public function testProcessInvokableCommand() $definition->addTag('console.command', [ 'command' => 'invokable', 'description' => 'The command description', + 'usages' => ['usage1', 'usage2'], 'help' => 'The %command.name% command help content.', ]); $container->setDefinition('invokable_command', $definition); @@ -325,6 +326,8 @@ public function testProcessInvokableCommand() self::assertTrue($container->has('invokable_command.command')); self::assertSame('The command description', $command->getDescription()); self::assertSame('The %command.name% command help content.', $command->getHelp()); + self::assertCount(2, $command->getUsages()); + $this->assertStringContainsString('usage1', $command->getUsages()[0]); } public function testProcessInvokableSignalableCommand() diff --git a/Tests/Descriptor/ApplicationDescriptionTest.php b/Tests/Descriptor/ApplicationDescriptionTest.php index 1933c985c..ab90320cd 100644 --- a/Tests/Descriptor/ApplicationDescriptionTest.php +++ b/Tests/Descriptor/ApplicationDescriptionTest.php @@ -25,7 +25,7 @@ public function testGetNamespaces(array $expected, array $names) { $application = new TestApplication(); foreach ($names as $name) { - $application->add(new Command($name)); + $application->addCommand(new Command($name)); } $this->assertSame($expected, array_keys((new ApplicationDescription($application))->getNamespaces())); diff --git a/Tests/Fixtures/DescriptorApplication2.php b/Tests/Fixtures/DescriptorApplication2.php index 7bb02fa54..c755bab38 100644 --- a/Tests/Fixtures/DescriptorApplication2.php +++ b/Tests/Fixtures/DescriptorApplication2.php @@ -18,9 +18,9 @@ class DescriptorApplication2 extends Application public function __construct() { parent::__construct('My Symfony application', 'v1.0'); - $this->add(new DescriptorCommand1()); - $this->add(new DescriptorCommand2()); - $this->add(new DescriptorCommand3()); - $this->add(new DescriptorCommand4()); + $this->addCommand(new DescriptorCommand1()); + $this->addCommand(new DescriptorCommand2()); + $this->addCommand(new DescriptorCommand3()); + $this->addCommand(new DescriptorCommand4()); } } diff --git a/Tests/Fixtures/DescriptorApplicationMbString.php b/Tests/Fixtures/DescriptorApplicationMbString.php index bf170c449..a76e0e181 100644 --- a/Tests/Fixtures/DescriptorApplicationMbString.php +++ b/Tests/Fixtures/DescriptorApplicationMbString.php @@ -19,6 +19,6 @@ public function __construct() { parent::__construct('MbString åpplicätion'); - $this->add(new DescriptorCommandMbString()); + $this->addCommand(new DescriptorCommandMbString()); } } diff --git a/Tests/Fixtures/InvokableExtendingCommandTestCommand.php b/Tests/Fixtures/InvokableExtendingCommandTestCommand.php new file mode 100644 index 000000000..724951608 --- /dev/null +++ b/Tests/Fixtures/InvokableExtendingCommandTestCommand.php @@ -0,0 +1,15 @@ +getStream(), $sections, $stream->getVerbosity(), $stream->isDecorated(), new OutputFormatter()); $bar = new ProgressBar($output, 50, 0); - $bar->setFormat('[%bar%] %percent:3s%%' . PHP_EOL . '%message%' . PHP_EOL); + $bar->setFormat('[%bar%] %percent:3s%%'.\PHP_EOL.'%message%'.\PHP_EOL); $bar->setMessage(''); $bar->start(); $bar->display(); @@ -435,8 +435,8 @@ public function testOverwriteWithSectionOutputAndEol() rewind($output->getStream()); $this->assertEquals(escapeshellcmd( '[>---------------------------] 0%'.\PHP_EOL.\PHP_EOL. - "\x1b[2A\x1b[0J".'[>---------------------------] 2%'.\PHP_EOL. 'Doing something...' . \PHP_EOL . - "\x1b[2A\x1b[0J".'[=>--------------------------] 4%'.\PHP_EOL. 'Doing something foo...' . \PHP_EOL), + "\x1b[2A\x1b[0J".'[>---------------------------] 2%'.\PHP_EOL.'Doing something...'.\PHP_EOL. + "\x1b[2A\x1b[0J".'[=>--------------------------] 4%'.\PHP_EOL.'Doing something foo...'.\PHP_EOL), escapeshellcmd(stream_get_contents($output->getStream())) ); } @@ -448,7 +448,7 @@ public function testOverwriteWithSectionOutputAndEolWithEmptyMessage() $output = new ConsoleSectionOutput($stream->getStream(), $sections, $stream->getVerbosity(), $stream->isDecorated(), new OutputFormatter()); $bar = new ProgressBar($output, 50, 0); - $bar->setFormat('[%bar%] %percent:3s%%' . PHP_EOL . '%message%'); + $bar->setFormat('[%bar%] %percent:3s%%'.\PHP_EOL.'%message%'); $bar->setMessage('Start'); $bar->start(); $bar->display(); @@ -460,8 +460,8 @@ public function testOverwriteWithSectionOutputAndEolWithEmptyMessage() rewind($output->getStream()); $this->assertEquals(escapeshellcmd( '[>---------------------------] 0%'.\PHP_EOL.'Start'.\PHP_EOL. - "\x1b[2A\x1b[0J".'[>---------------------------] 2%'.\PHP_EOL . - "\x1b[1A\x1b[0J".'[=>--------------------------] 4%'.\PHP_EOL. 'Doing something...' . \PHP_EOL), + "\x1b[2A\x1b[0J".'[>---------------------------] 2%'.\PHP_EOL. + "\x1b[1A\x1b[0J".'[=>--------------------------] 4%'.\PHP_EOL.'Doing something...'.\PHP_EOL), escapeshellcmd(stream_get_contents($output->getStream())) ); } @@ -473,7 +473,7 @@ public function testOverwriteWithSectionOutputAndEolWithEmptyMessageComment() $output = new ConsoleSectionOutput($stream->getStream(), $sections, $stream->getVerbosity(), $stream->isDecorated(), new OutputFormatter()); $bar = new ProgressBar($output, 50, 0); - $bar->setFormat('[%bar%] %percent:3s%%' . PHP_EOL . '%message%'); + $bar->setFormat('[%bar%] %percent:3s%%'.\PHP_EOL.'%message%'); $bar->setMessage('Start'); $bar->start(); $bar->display(); @@ -485,8 +485,8 @@ public function testOverwriteWithSectionOutputAndEolWithEmptyMessageComment() rewind($output->getStream()); $this->assertEquals(escapeshellcmd( '[>---------------------------] 0%'.\PHP_EOL."\x1b[33mStart\x1b[39m".\PHP_EOL. - "\x1b[2A\x1b[0J".'[>---------------------------] 2%'.\PHP_EOL . - "\x1b[1A\x1b[0J".'[=>--------------------------] 4%'.\PHP_EOL. "\x1b[33mDoing something...\x1b[39m" . \PHP_EOL), + "\x1b[2A\x1b[0J".'[>---------------------------] 2%'.\PHP_EOL. + "\x1b[1A\x1b[0J".'[=>--------------------------] 4%'.\PHP_EOL."\x1b[33mDoing something...\x1b[39m".\PHP_EOL), escapeshellcmd(stream_get_contents($output->getStream())) ); } diff --git a/Tests/Helper/TableTest.php b/Tests/Helper/TableTest.php index 52ae23301..eb85364da 100644 --- a/Tests/Helper/TableTest.php +++ b/Tests/Helper/TableTest.php @@ -2099,12 +2099,12 @@ public function testGithubIssue60038WidthOfCellWithEmoji() ->setHeaderTitle('Test Title') ->setHeaders(['Title', 'Author']) ->setRows([ - ["🎭 💫 ☯"." Divine Comedy", "Dante Alighieri"], + ['🎭 💫 ☯ Divine Comedy', 'Dante Alighieri'], // the snowflake (e2 9d 84 ef b8 8f) has a variant selector - ["👑 ❄️ 🗡"." Game of Thrones", "George R.R. Martin"], + ['👑 ❄️ 🗡 Game of Thrones', 'George R.R. Martin'], // the snowflake in text style (e2 9d 84 ef b8 8e) has a variant selector - ["❄︎❄︎❄︎ snowflake in text style ❄︎❄︎❄︎", ""], - ["And a very long line to show difference in previous lines", ""], + ['❄︎❄︎❄︎ snowflake in text style ❄︎❄︎❄︎', ''], + ['And a very long line to show difference in previous lines', ''], ]) ; $table->render(); diff --git a/Tests/Tester/CommandTesterTest.php b/Tests/Tester/CommandTesterTest.php index cfdebe4d8..b7490ad3f 100644 --- a/Tests/Tester/CommandTesterTest.php +++ b/Tests/Tester/CommandTesterTest.php @@ -23,6 +23,8 @@ use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; use Symfony\Component\Console\Tester\CommandTester; +use Symfony\Component\Console\Tests\Fixtures\InvokableExtendingCommandTestCommand; +use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand; class CommandTesterTest extends TestCase { @@ -104,7 +106,7 @@ public function testCommandFromApplication() return 0; }); - $application->add($command); + $application->addCommand($command); $tester = new CommandTester($application->find('foo')); @@ -267,4 +269,24 @@ public function testErrorOutput() $this->assertSame('foo', $tester->getErrorOutput()); } + + public function testAInvokableCommand() + { + $command = new InvokableTestCommand(); + + $tester = new CommandTester($command); + $tester->execute([]); + + $tester->assertCommandIsSuccessful(); + } + + public function testAInvokableExtendedCommand() + { + $command = new InvokableExtendingCommandTestCommand(); + + $tester = new CommandTester($command); + $tester->execute([]); + + $tester->assertCommandIsSuccessful(); + } } diff --git a/Tests/phpt/alarm/command_exit.phpt b/Tests/phpt/alarm/command_exit.phpt index c2cf3edc7..a53af8567 100644 --- a/Tests/phpt/alarm/command_exit.phpt +++ b/Tests/phpt/alarm/command_exit.phpt @@ -53,7 +53,7 @@ class MyCommand extends Command $app = new Application(); $app->setDispatcher(new \Symfony\Component\EventDispatcher\EventDispatcher()); -$app->add(new MyCommand('foo')); +$app->addCommand(new MyCommand('foo')); $app ->setDefaultCommand('foo', true) diff --git a/Tests/phpt/signal/command_exit.phpt b/Tests/phpt/signal/command_exit.phpt index e14f80c47..e653d65c1 100644 --- a/Tests/phpt/signal/command_exit.phpt +++ b/Tests/phpt/signal/command_exit.phpt @@ -45,7 +45,7 @@ class MyCommand extends Command $app = new Application(); $app->setDispatcher(new \Symfony\Component\EventDispatcher\EventDispatcher()); -$app->add(new MyCommand('foo')); +$app->addCommand(new MyCommand('foo')); $app ->setDefaultCommand('foo', true) diff --git a/composer.json b/composer.json index 65d69913a..109cdd762 100644 --- a/composer.json +++ b/composer.json @@ -20,19 +20,19 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^7.2" + "symfony/string": "^7.2|^8.0" }, "require-dev": { - "symfony/config": "^6.4|^7.0", - "symfony/event-dispatcher": "^6.4|^7.0", - "symfony/http-foundation": "^6.4|^7.0", - "symfony/http-kernel": "^6.4|^7.0", - "symfony/dependency-injection": "^6.4|^7.0", - "symfony/lock": "^6.4|^7.0", - "symfony/messenger": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0", - "symfony/stopwatch": "^6.4|^7.0", - "symfony/var-dumper": "^6.4|^7.0", + "symfony/config": "^6.4|^7.0|^8.0", + "symfony/event-dispatcher": "^6.4|^7.0|^8.0", + "symfony/http-foundation": "^6.4|^7.0|^8.0", + "symfony/http-kernel": "^6.4|^7.0|^8.0", + "symfony/dependency-injection": "^6.4|^7.0|^8.0", + "symfony/lock": "^6.4|^7.0|^8.0", + "symfony/messenger": "^6.4|^7.0|^8.0", + "symfony/process": "^6.4|^7.0|^8.0", + "symfony/stopwatch": "^6.4|^7.0|^8.0", + "symfony/var-dumper": "^6.4|^7.0|^8.0", "psr/log": "^1|^2|^3" }, "provide": {