From cad88690ff8148d24bc815c0974f387d1771c999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Mon, 26 May 2025 11:00:21 +0200 Subject: [PATCH] [Console] Support enum in invokable commands Co-authored-by: Nicolas Grekas --- .../Component/Console/Attribute/Argument.php | 27 ++++-- .../Component/Console/Attribute/Option.php | 16 +++- src/Symfony/Component/Console/CHANGELOG.md | 1 + .../Exception/InvalidArgumentException.php | 13 +++ .../Exception/InvalidOptionException.php | 13 +++ .../Tests/Command/InvokableCommandTest.php | 90 +++++++++++++++++++ 6 files changed, 151 insertions(+), 9 deletions(-) diff --git a/src/Symfony/Component/Console/Attribute/Argument.php b/src/Symfony/Component/Console/Attribute/Argument.php index e6a94d2f10e4c..f2c813d3b1a0f 100644 --- a/src/Symfony/Component/Console/Attribute/Argument.php +++ b/src/Symfony/Component/Console/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/src/Symfony/Component/Console/Attribute/Option.php b/src/Symfony/Component/Console/Attribute/Option.php index 2f0256b177658..8065d6ad82ed8 100644 --- a/src/Symfony/Component/Console/Attribute/Option.php +++ b/src/Symfony/Component/Console/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; } diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index f481d55aa7b36..f5e15ade7390d 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * 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 7.3 --- diff --git a/src/Symfony/Component/Console/Exception/InvalidArgumentException.php b/src/Symfony/Component/Console/Exception/InvalidArgumentException.php index 07cc0b61d6dc8..0482244f2066b 100644 --- a/src/Symfony/Component/Console/Exception/InvalidArgumentException.php +++ b/src/Symfony/Component/Console/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/src/Symfony/Component/Console/Exception/InvalidOptionException.php b/src/Symfony/Component/Console/Exception/InvalidOptionException.php index 5cf62792e43c8..e59167df12fe9 100644 --- a/src/Symfony/Component/Console/Exception/InvalidOptionException.php +++ b/src/Symfony/Component/Console/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/src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php b/src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php index 5ab7951e7f575..785891586ca83 100644 --- a/src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php +++ b/src/Symfony/Component/Console/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'); @@ -377,3 +461,9 @@ public function getSuggestedRoles(CompletionInput $input): array return ['ROLE_ADMIN', 'ROLE_USER']; } } + +enum StringEnum: string +{ + case Image = 'image'; + case Video = 'video'; +}