Skip to content

Commit b87a8fe

Browse files
committed
[Console] Invokable command #[Option] adjustments
- `#[Option] ?string $opt = null` as `VALUE_REQUIRED` - `#[Option] bool|string $opt = false` as `VALUE_OPTIONAL` - `#[Option] ?string $opt = ''` throws exception - allow `#[Option] ?array $opt = null`
1 parent db8e84d commit b87a8fe

File tree

2 files changed

+97
-35
lines changed

2 files changed

+97
-35
lines changed

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

Lines changed: 53 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
class Option
2323
{
2424
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
25+
private const ALLOWED_UNION_TYPES = ['bool|string', 'bool|int', 'bool|float'];
2526

2627
private string|bool|int|float|array|null $default = null;
2728
private array|\Closure $suggestedValues;
@@ -56,18 +57,8 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
5657
return null;
5758
}
5859

59-
$type = $parameter->getType();
6060
$name = $parameter->getName();
61-
62-
if (!$type instanceof \ReflectionNamedType) {
63-
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command options.', $name));
64-
}
65-
66-
$self->typeName = $type->getName();
67-
68-
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) {
69-
throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES)));
70-
}
61+
$type = $parameter->getType();
7162

7263
if (!$parameter->isDefaultValueAvailable()) {
7364
throw new LogicException(\sprintf('The option parameter "$%s" must declare a default value.', $name));
@@ -80,28 +71,37 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
8071
$self->default = $parameter->getDefaultValue();
8172
$self->allowNull = $parameter->allowsNull();
8273

83-
if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) {
84-
throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable when it has a default boolean value.', $name));
74+
if ($type instanceof \ReflectionUnionType) {
75+
return self::handleUnion($self, $type);
76+
}
77+
78+
if (!$type instanceof \ReflectionNamedType) {
79+
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped or Intersection types are not supported for command options.', $name));
80+
}
81+
82+
$self->typeName = $type->getName();
83+
84+
if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) {
85+
throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES)));
8586
}
8687

87-
if ('string' === $self->typeName && null === $self->default) {
88-
throw new LogicException(\sprintf('The option parameter "$%s" must not have a default of null.', $name));
88+
if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) {
89+
throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable when it has a default boolean value.', $name));
8990
}
9091

91-
if ('array' === $self->typeName && $self->allowNull) {
92-
throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable.', $name));
92+
if ($self->allowNull && null !== $self->default) {
93+
throw new LogicException(\sprintf('The option parameter "$%s" must either be not-nullable or have a default of null.', $name));
9394
}
9495

9596
if ('bool' === $self->typeName) {
9697
$self->mode = InputOption::VALUE_NONE;
9798
if (false !== $self->default) {
9899
$self->mode |= InputOption::VALUE_NEGATABLE;
99100
}
101+
} elseif ('array' === $self->typeName) {
102+
$self->mode = InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY;
100103
} else {
101-
$self->mode = $self->allowNull ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
102-
if ('array' === $self->typeName) {
103-
$self->mode |= InputOption::VALUE_IS_ARRAY;
104-
}
104+
$self->mode = InputOption::VALUE_REQUIRED;
105105
}
106106

107107
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]])) {
@@ -129,6 +129,14 @@ public function resolveValue(InputInterface $input): mixed
129129
{
130130
$value = $input->getOption($this->name);
131131

132+
if (null === $value && \in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) {
133+
return true;
134+
}
135+
136+
if ('array' === $this->typeName && $this->allowNull && [] === $value) {
137+
return null;
138+
}
139+
132140
if ('bool' !== $this->typeName) {
133141
return $value;
134142
}
@@ -139,4 +147,28 @@ public function resolveValue(InputInterface $input): mixed
139147

140148
return $value ?? $this->default;
141149
}
150+
151+
private static function handleUnion(self $self, \ReflectionUnionType $type): self
152+
{
153+
$types = array_map(
154+
static fn(\ReflectionType $t) => $t instanceof \ReflectionNamedType ? $t->getName() : null,
155+
$type->getTypes(),
156+
);
157+
158+
sort($types);
159+
160+
$self->typeName = implode('|', array_filter($types));
161+
162+
if (!\in_array($self->typeName, self::ALLOWED_UNION_TYPES, true)) {
163+
throw new LogicException(\sprintf('The union type for parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->name, implode('", "', self::ALLOWED_UNION_TYPES)));
164+
}
165+
166+
if (false !== $self->default) {
167+
throw new LogicException(\sprintf('The option parameter "$%s" must have a default value of false.', $self->name));
168+
}
169+
170+
$self->mode = InputOption::VALUE_OPTIONAL;
171+
172+
return $self;
173+
}
142174
}

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

Lines changed: 44 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -79,14 +79,16 @@ public function testCommandInputOptionDefinition()
7979
#[Option(shortcut: 'v')] bool $verbose = false,
8080
#[Option(description: 'User groups')] array $groups = [],
8181
#[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'],
82+
#[Option] string|bool $opt = false,
8283
): int {
8384
return 0;
8485
});
8586

8687
$timeoutInputOption = $command->getDefinition()->getOption('idle');
8788
self::assertSame('idle', $timeoutInputOption->getName());
8889
self::assertNull($timeoutInputOption->getShortcut());
89-
self::assertTrue($timeoutInputOption->isValueOptional());
90+
self::assertTrue($timeoutInputOption->isValueRequired());
91+
self::assertFalse($timeoutInputOption->isValueOptional());
9092
self::assertFalse($timeoutInputOption->isNegatable());
9193
self::assertNull($timeoutInputOption->getDefault());
9294

@@ -120,6 +122,14 @@ public function testCommandInputOptionDefinition()
120122
self::assertTrue($rolesInputOption->hasCompletion());
121123
$rolesInputOption->complete(new CompletionInput(), $suggestions = new CompletionSuggestions());
122124
self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions()));
125+
126+
$optInputOption = $command->getDefinition()->getOption('opt');
127+
self::assertSame('opt', $optInputOption->getName());
128+
self::assertNull($optInputOption->getShortcut());
129+
self::assertFalse($optInputOption->isValueRequired());
130+
self::assertTrue($optInputOption->isValueOptional());
131+
self::assertFalse($optInputOption->isNegatable());
132+
self::assertFalse($optInputOption->getDefault());
123133
}
124134

125135
public function testInvalidArgumentType()
@@ -136,7 +146,7 @@ public function testInvalidArgumentType()
136146
public function testInvalidOptionType()
137147
{
138148
$command = new Command('foo');
139-
$command->setCode(function (#[Option] object $any) {});
149+
$command->setCode(function (#[Option] ?object $any = null) {});
140150

141151
$this->expectException(LogicException::class);
142152
$this->expectExceptionMessage('The type "object" of parameter "$any" is not supported as a command option. Only "string", "bool", "int", "float", "array" types are allowed.');
@@ -262,14 +272,18 @@ public function testNonBinaryInputOptions(array $parameters, array $expected)
262272
$command = new Command('foo');
263273
$command->setCode(function (
264274
#[Option] string $a = '',
265-
#[Option] ?string $b = '',
266-
#[Option] array $c = [],
267-
#[Option] array $d = ['a', 'b'],
275+
#[Option] array $b = [],
276+
#[Option] array $c = ['a', 'b'],
277+
#[Option] bool|string $d = false,
278+
#[Option] ?string $e = null,
279+
#[Option] ?array $f = null,
268280
) use ($expected): int {
269281
$this->assertSame($expected[0], $a);
270282
$this->assertSame($expected[1], $b);
271283
$this->assertSame($expected[2], $c);
272284
$this->assertSame($expected[3], $d);
285+
$this->assertSame($expected[4], $e);
286+
$this->assertSame($expected[5], $f);
273287

274288
return 0;
275289
});
@@ -279,9 +293,9 @@ public function testNonBinaryInputOptions(array $parameters, array $expected)
279293

280294
public static function provideNonBinaryInputOptions(): \Generator
281295
{
282-
yield 'defaults' => [[], ['', '', [], ['a', 'b']]];
283-
yield 'with-value' => [['--a' => 'x', '--b' => 'y', '--c' => ['z'], '--d' => ['c', 'd']], ['x', 'y', ['z'], ['c', 'd']]];
284-
yield 'without-value' => [['--b' => null], ['', null, [], ['a', 'b']]];
296+
yield 'defaults' => [[], ['', [], ['a', 'b'], false, null, null]];
297+
yield 'with-value' => [['--a' => 'x', '--b' => ['z'], '--c' => ['c', 'd'], '--d' => 'v', '--e' => 'w', '--f' => ['q']], ['x', ['z'], ['c', 'd'], 'v', 'w', ['q']]];
298+
yield 'without-value' => [['--d' => null], ['', [], ['a', 'b'], true, null, null]];
285299
}
286300

287301
/**
@@ -312,13 +326,29 @@ function (#[Option] ?bool $a = true) {},
312326
function (#[Option] ?bool $a = false) {},
313327
'The option parameter "$a" must not be nullable when it has a default boolean value.',
314328
];
315-
yield 'nullable-string' => [
316-
function (#[Option] ?string $a = null) {},
317-
'The option parameter "$a" must not have a default of null.',
329+
yield 'invalid-union-type' => [
330+
function (#[Option] array|bool $a = false) {},
331+
'The union type for parameter "$a" is not supported as a command option. Only "bool|string", "bool|int", "bool|float" types are allowed.',
332+
];
333+
yield 'union-type-cannot-allow-null' => [
334+
function (#[Option] string|bool $a = null) {},
335+
'The union type for parameter "$a" is not supported as a command option. Only "bool|string", "bool|int", "bool|float" types are allowed.',
336+
];
337+
yield 'union-type-default-true' => [
338+
function (#[Option] string|bool $a = true) {},
339+
'The option parameter "$a" must have a default value of false.',
340+
];
341+
yield 'union-type-default-string' => [
342+
function (#[Option] string|bool $a = 'foo') {},
343+
'The option parameter "$a" must have a default value of false.',
344+
];
345+
yield 'nullable-string-not-null-default' => [
346+
function (#[Option] ?string $a = 'foo') {},
347+
'The option parameter "$a" must either be not-nullable or have a default of null.',
318348
];
319-
yield 'nullable-array' => [
320-
function (#[Option] ?array $a = null) {},
321-
'The option parameter "$a" must not be nullable.',
349+
yield 'nullable-array-not-null-default' => [
350+
function (#[Option] ?array $a = []) {},
351+
'The option parameter "$a" must either be not-nullable or have a default of null.',
322352
];
323353
}
324354

0 commit comments

Comments
 (0)