Skip to content

Commit 7a99078

Browse files
committed
[Console] Support enum in invokable commands
1 parent 298e56a commit 7a99078

File tree

6 files changed

+170
-9
lines changed

6 files changed

+170
-9
lines changed

src/Symfony/Component/Console/Attribute/Argument.php

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\Console\Completion\CompletionInput;
1515
use Symfony\Component\Console\Completion\Suggestion;
16+
use Symfony\Component\Console\Exception\InvalidArgumentException;
1617
use Symfony\Component\Console\Exception\LogicException;
1718
use Symfony\Component\Console\Input\InputArgument;
1819
use Symfony\Component\Console\Input\InputInterface;
@@ -27,6 +28,7 @@ class Argument
2728
private array|\Closure $suggestedValues;
2829
private ?int $mode = null;
2930
private string $function = '';
31+
private string $typeName = '';
3032

3133
/**
3234
* Represents a console command <argument> definition.
@@ -66,27 +68,39 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
6668
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));
6769
}
6870

69-
$parameterTypeName = $type->getName();
71+
$self->typeName = $type->getName();
72+
$isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class);
7073

71-
if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) {
72-
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)));
74+
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) {
75+
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types and BackedEnum are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
7376
}
7477

7578
if (!$self->name) {
7679
$self->name = (new UnicodeString($name))->kebab();
7780
}
7881

79-
$self->default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
82+
if ($parameter->isDefaultValueAvailable()) {
83+
$self->default = $parameter->getDefaultValue() instanceof \BackedEnum
84+
? $parameter->getDefaultValue()->value
85+
: $parameter->getDefaultValue();
86+
}
8087

8188
$self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
82-
if ('array' === $parameterTypeName) {
89+
if ('array' === $self->typeName) {
8390
$self->mode |= InputArgument::IS_ARRAY;
8491
}
8592

8693
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]])) {
8794
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
8895
}
8996

97+
if ($isBackedEnum && !$self->suggestedValues) {
98+
$self->suggestedValues = array_map(
99+
static fn (\BackedEnum $enum) => (string) $enum->value,
100+
($self->typeName)::cases(),
101+
);
102+
}
103+
90104
return $self;
91105
}
92106

@@ -105,6 +119,16 @@ public function toInputArgument(): InputArgument
105119
*/
106120
public function resolveValue(InputInterface $input): mixed
107121
{
108-
return $input->getArgument($this->name);
122+
$value = $input->getArgument($this->name);
123+
124+
if (is_subclass_of($this->typeName, \BackedEnum::class) && (is_string($value) || is_int($value))) {
125+
try {
126+
$value = ($this->typeName)::from($value);
127+
} catch (\ValueError) {
128+
throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues);
129+
}
130+
}
131+
132+
return $value;
109133
}
110134
}

src/Symfony/Component/Console/Attribute/Option.php

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
use Symfony\Component\Console\Completion\CompletionInput;
1515
use Symfony\Component\Console\Completion\Suggestion;
16+
use Symfony\Component\Console\Exception\InvalidOptionException;
1617
use Symfony\Component\Console\Exception\LogicException;
1718
use Symfony\Component\Console\Input\InputInterface;
1819
use Symfony\Component\Console\Input\InputOption;
@@ -75,7 +76,9 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
7576
$self->name = (new UnicodeString($name))->kebab();
7677
}
7778

78-
$self->default = $parameter->getDefaultValue();
79+
$self->default = $parameter->getDefaultValue() instanceof \BackedEnum
80+
? $parameter->getDefaultValue()->value
81+
: $parameter->getDefaultValue();
7982
$self->allowNull = $parameter->allowsNull();
8083

8184
if ($type instanceof \ReflectionUnionType) {
@@ -87,9 +90,10 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
8790
}
8891

8992
$self->typeName = $type->getName();
93+
$isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class);
9094

91-
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) {
92-
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)));
95+
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) {
96+
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)));
9397
}
9498

9599
if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) {
@@ -115,6 +119,13 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
115119
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
116120
}
117121

122+
if ($isBackedEnum && !$self->suggestedValues) {
123+
$self->suggestedValues = array_map(
124+
static fn (\BackedEnum $enum) => (string) $enum->value,
125+
($self->typeName)::cases(),
126+
);
127+
}
128+
118129
return $self;
119130
}
120131

@@ -140,6 +151,14 @@ public function resolveValue(InputInterface $input): mixed
140151
return true;
141152
}
142153

154+
if (is_subclass_of($this->typeName, \BackedEnum::class) && (is_string($value) || is_int($value))) {
155+
try {
156+
$value = ($this->typeName)::from($value);
157+
} catch (\ValueError) {
158+
throw InvalidOptionException::fromEnumValue($this->name, $value, $this->suggestedValues);
159+
}
160+
}
161+
143162
if ('array' === $this->typeName && $this->allowNull && [] === $value) {
144163
return null;
145164
}

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.4
5+
---
6+
7+
* Add `BackedEnum` support with `#[Argument]` and `#[Option]` inputs in invokable commands
8+
49
7.3
510
---
611

src/Symfony/Component/Console/Exception/InvalidArgumentException.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,15 @@
1616
*/
1717
class InvalidArgumentException extends \InvalidArgumentException implements ExceptionInterface
1818
{
19+
/** @internal */
20+
public static function fromEnumValue(string $name, string $value, array|\Closure $suggestedValues): self
21+
{
22+
$error = \sprintf('The value "%s" is not valid for the "%s" argument.', $value, $name);
23+
24+
if (is_array($suggestedValues)) {
25+
$error .= \sprintf(' Supported values are "%s".', implode('", "', $suggestedValues));
26+
}
27+
28+
return new self($error);
29+
}
1930
}

src/Symfony/Component/Console/Exception/InvalidOptionException.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,4 +18,15 @@
1818
*/
1919
class InvalidOptionException extends \InvalidArgumentException implements ExceptionInterface
2020
{
21+
/** @internal */
22+
public static function fromEnumValue(string $name, string $value, array|\Closure $suggestedValues): self
23+
{
24+
$error = \sprintf('The value "%s" is not valid for the "%s" option.', $value, $name);
25+
26+
if (is_array($suggestedValues)) {
27+
$error .= \sprintf(' Supported values are "%s".', implode('", "', $suggestedValues));
28+
}
29+
30+
return new self($error);
31+
}
2132
}

src/Symfony/Component/Console/Tests/Command/InvokableCommandTest.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,15 @@
1111

1212
namespace Symfony\Component\Console\Tests\Command;
1313

14+
use PHPUnit\Framework\Assert;
1415
use PHPUnit\Framework\TestCase;
1516
use Symfony\Component\Console\Attribute\Argument;
1617
use Symfony\Component\Console\Attribute\Option;
1718
use Symfony\Component\Console\Command\Command;
1819
use Symfony\Component\Console\Completion\CompletionInput;
1920
use Symfony\Component\Console\Completion\CompletionSuggestions;
2021
use Symfony\Component\Console\Completion\Suggestion;
22+
use Symfony\Component\Console\Exception\InvalidArgumentException;
2123
use Symfony\Component\Console\Exception\InvalidOptionException;
2224
use Symfony\Component\Console\Exception\LogicException;
2325
use Symfony\Component\Console\Input\ArrayInput;
@@ -132,6 +134,88 @@ public function testCommandInputOptionDefinition()
132134
self::assertFalse($optInputOption->getDefault());
133135
}
134136

137+
public function testEnumArgument()
138+
{
139+
$command = new Command('foo');
140+
$command->setCode(function (
141+
#[Argument] StringEnum $enum,
142+
#[Argument] StringEnum $enumWithDefault = StringEnum::Image,
143+
#[Argument] ?StringEnum $nullableEnum = null,
144+
): int {
145+
Assert::assertSame(StringEnum::Image, $enum);
146+
Assert::assertSame(StringEnum::Image, $enumWithDefault);
147+
Assert::assertNull($nullableEnum);
148+
149+
return 0;
150+
});
151+
152+
$enumInputArgument = $command->getDefinition()->getArgument('enum');
153+
self::assertTrue($enumInputArgument->isRequired());
154+
self::assertNull($enumInputArgument->getDefault());
155+
self::assertTrue($enumInputArgument->hasCompletion());
156+
157+
$enumWithDefaultInputArgument = $command->getDefinition()->getArgument('enum-with-default');
158+
self::assertFalse($enumWithDefaultInputArgument->isRequired());
159+
self::assertSame('image', $enumWithDefaultInputArgument->getDefault());
160+
self::assertTrue($enumWithDefaultInputArgument->hasCompletion());
161+
162+
$nullableEnumInputArgument = $command->getDefinition()->getArgument('nullable-enum');
163+
self::assertFalse($nullableEnumInputArgument->isRequired());
164+
self::assertNull($nullableEnumInputArgument->getDefault());
165+
self::assertTrue($nullableEnumInputArgument->hasCompletion());
166+
167+
$enumInputArgument->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions());
168+
self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions());
169+
170+
$command->run(new ArrayInput(['enum' => 'image']), new NullOutput());
171+
172+
self::expectException(InvalidArgumentException::class);
173+
self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" argument. Supported values are "image", "video".');
174+
175+
$command->run(new ArrayInput(['enum' => 'incorrect']), new NullOutput());
176+
}
177+
178+
public function testEnumOption()
179+
{
180+
$command = new Command('foo');
181+
$command->setCode(function (
182+
#[Option] StringEnum $enum = StringEnum::Video,
183+
#[Option] StringEnum $enumWithDefault = StringEnum::Image,
184+
#[Option] ?StringEnum $nullableEnum = null,
185+
): int {
186+
Assert::assertSame(StringEnum::Image, $enum);
187+
Assert::assertSame(StringEnum::Image, $enumWithDefault);
188+
Assert::assertNull($nullableEnum);
189+
190+
return 0;
191+
});
192+
193+
$enumInputOption = $command->getDefinition()->getOption('enum');
194+
self::assertTrue($enumInputOption->isValueRequired());
195+
self::assertSame('video', $enumInputOption->getDefault());
196+
self::assertTrue($enumInputOption->hasCompletion());
197+
198+
$enumWithDefaultInputOption = $command->getDefinition()->getOption('enum-with-default');
199+
self::assertTrue($enumWithDefaultInputOption->isValueRequired());
200+
self::assertSame('image', $enumWithDefaultInputOption->getDefault());
201+
self::assertTrue($enumWithDefaultInputOption->hasCompletion());
202+
203+
$nullableEnumInputOption = $command->getDefinition()->getOption('nullable-enum');
204+
self::assertTrue($nullableEnumInputOption->isValueRequired());
205+
self::assertNull($nullableEnumInputOption->getDefault());
206+
self::assertTrue($nullableEnumInputOption->hasCompletion());
207+
208+
$enumInputOption->complete(CompletionInput::fromTokens([], 0), $suggestions = new CompletionSuggestions());
209+
self::assertEquals([new Suggestion('image'), new Suggestion('video')], $suggestions->getValueSuggestions());
210+
211+
$command->run(new ArrayInput(['--enum' => 'image']), new NullOutput());
212+
213+
self::expectException(InvalidOptionException::class);
214+
self::expectExceptionMessage('The value "incorrect" is not valid for the "enum" option. Supported values are "image", "video".');
215+
216+
$command->run(new ArrayInput(['--enum' => 'incorrect']), new NullOutput());
217+
}
218+
135219
public function testInvalidArgumentType()
136220
{
137221
$command = new Command('foo');
@@ -377,3 +461,10 @@ public function getSuggestedRoles(CompletionInput $input): array
377461
return ['ROLE_ADMIN', 'ROLE_USER'];
378462
}
379463
}
464+
465+
466+
enum StringEnum: string
467+
{
468+
case Image = 'image';
469+
case Video = 'video';
470+
}

0 commit comments

Comments
 (0)