Skip to content

Commit 29ec76c

Browse files
committed
[Console] Fine-tuning the interactive Ask attribute
1 parent c0177fc commit 29ec76c

File tree

6 files changed

+69
-15
lines changed

6 files changed

+69
-15
lines changed

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,4 +134,12 @@ public function getInteractiveAttribute(): ?InteractiveAttributeInterface
134134
{
135135
return $this->interactiveAttribute;
136136
}
137+
138+
/**
139+
* @internal
140+
*/
141+
public function isRequired(): bool
142+
{
143+
return InputArgument::REQUIRED === (InputArgument::REQUIRED & $this->mode);
144+
}
137145
}

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

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

1414
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
1515
use Symfony\Component\Console\Exception\InvalidArgumentException;
16+
use Symfony\Component\Console\Exception\LogicException;
1617
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Question\ConfirmationQuestion;
1719
use Symfony\Component\Console\Question\Question;
1820
use Symfony\Component\Console\Style\SymfonyStyle;
1921

2022
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
2123
class Ask implements InteractiveAttributeInterface
2224
{
25+
public ?\Closure $normalizer;
2326
public ?\Closure $validator;
2427
private \Closure $closure;
2528

@@ -41,9 +44,11 @@ public function __construct(
4144
public bool $multiline = false,
4245
public bool $trimmable = true,
4346
public ?int $timeout = null,
47+
?callable $normalizer = null,
4448
?callable $validator = null,
4549
public ?int $maxAttempts = null,
4650
) {
51+
$this->normalizer = $normalizer ? $normalizer(...) : null;
4752
$this->validator = $validator ? $validator(...) : null;
4853
}
4954

@@ -58,18 +63,38 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member,
5863
return null;
5964
}
6065

61-
$self->closure = function (SymfonyStyle $io, InputInterface $input) use ($self, $reflection, $name) {
62-
if (($reflection->isProperty() && isset($this->{$reflection->getName()})) || ($reflection->isParameter() && null !== $input->getArgument($name))) {
66+
$type = $reflection->getType();
67+
68+
if (!$type instanceof \ReflectionNamedType) {
69+
throw new LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped, Union or Intersection types are not supported for interactive questions.', $reflection->getMemberName(), $name, $reflection->getSourceName()));
70+
}
71+
72+
$self->closure = function (SymfonyStyle $io, InputInterface $input) use ($self, $reflection, $name, $type) {
73+
if ($reflection->isProperty() && isset($this->{$reflection->getName()})) {
74+
return;
75+
}
76+
77+
if ($reflection->isParameter() && !\in_array($input->getArgument($name), [null, []], true)) {
6378
return;
6479
}
6580

66-
$question = new Question($self->question, $self->default);
81+
if ('bool' === $type->getName()) {
82+
$self->default ??= false;
83+
84+
if (!\is_bool($self->default)) {
85+
throw new LogicException(\sprintf('The "%s::$default" value for the %s "$%s" of "%s" must be a boolean.', self::class, $reflection->getMemberName(), $name, $reflection->getSourceName()));
86+
}
87+
88+
$question = new ConfirmationQuestion($self->question, $self->default);
89+
} else {
90+
$question = new Question($self->question, $self->default);
91+
}
6792
$question->setHidden($self->hidden);
6893
$question->setMultiline($self->multiline);
6994
$question->setTrimmable($self->trimmable);
7095
$question->setTimeout($self->timeout);
7196

72-
if (!$self->validator && $reflection->isProperty()) {
97+
if (!$self->validator && $reflection->isProperty() && 'array' !== $type->getName()) {
7398
$self->validator = function (mixed $value) use ($reflection): mixed {
7499
return $this->{$reflection->getName()} = $value;
75100
};
@@ -78,13 +103,25 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member,
78103
$question->setValidator($self->validator);
79104
$question->setMaxAttempts($self->maxAttempts);
80105

81-
if ($reflection->isBackedEnumType()) {
106+
if ($self->normalizer) {
107+
$question->setNormalizer($self->normalizer);
108+
} elseif (is_subclass_of($type->getName(), \BackedEnum::class)) {
82109
/** @var class-string<\BackedEnum> $backedType */
83110
$backedType = $reflection->getType()->getName();
84-
$question->setNormalizer(fn (string|int $value) => $backedType::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($reflection->getName(), $value, array_map(fn (\BackedEnum $enum): string|int => $enum->value, $backedType::cases())));
111+
$question->setNormalizer(fn (string|int $value) => $backedType::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($reflection->getName(), $value, array_column($backedType::cases(), 'value')));
85112
}
86113

87-
$value = $io->askQuestion($question);
114+
if ('array' === $type->getName()) {
115+
$value = [];
116+
while ($v = $io->askQuestion($question)) {
117+
if ("\x4" === $v || \PHP_EOL === $v || ($question->isTrimmable() && '' === $v = trim($v))) {
118+
break;
119+
}
120+
$value[] = $v;
121+
}
122+
} else {
123+
$value = $io->askQuestion($question);
124+
}
88125

89126
if (null === $value && !$reflection->isNullable()) {
90127
return;

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ public function resolveValue(InputInterface $input): object
9393

9494
foreach ($this->definition as $name => $spec) {
9595
// ignore required arguments that are not set yet (may happen in interactive mode)
96-
if ($spec instanceof Argument && null === $input->getArgument($spec->name) && $spec->toInputArgument()->isRequired()) {
96+
if ($spec instanceof Argument && $spec->isRequired() && \in_array($input->getArgument($spec->name), [null, []], true)) {
9797
continue;
9898
}
9999

@@ -111,7 +111,7 @@ public function setValue(InputInterface $input, object $object): void
111111
foreach ($this->definition as $name => $spec) {
112112
$property = $this->class->getProperty($name);
113113

114-
if (!$property->isInitialized($object) || null === $value = $property->getValue($object)) {
114+
if (!$property->isInitialized($object) || \in_array($value = $property->getValue($object), [null, []], true)) {
115115
continue;
116116
}
117117

src/Symfony/Component/Console/Attribute/Reflection/ReflectionMember.php

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,11 +97,6 @@ public function getMemberName(): string
9797
return $this->member instanceof \ReflectionParameter ? 'parameter' : 'property';
9898
}
9999

100-
public function isBackedEnumType(): bool
101-
{
102-
return $this->member->getType() instanceof \ReflectionNamedType && is_subclass_of($this->member->getType()->getName(), \BackedEnum::class);
103-
}
104-
105100
public function isParameter(): bool
106101
{
107102
return $this->member instanceof \ReflectionParameter;

src/Symfony/Component/Console/Tests/Fixtures/InvokableWithInteractiveAttributesTestCommand.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ public function __invoke(
4444
$io->writeln('Arg5: '.$dto->arg5);
4545
$io->writeln('Arg6: '.$dto->dummyDto2->arg6);
4646
$io->writeln('Arg7: '.$dto->dummyDto2->arg7);
47+
$io->writeln('Arg8: '.($dto->dummyDto2->arg8 ? 'yes' : 'no'));
48+
$io->writeln('Arg9: '.implode(',', $dto->dummyDto2->arg9));
4749

4850
return Command::SUCCESS;
4951
}
@@ -85,6 +87,14 @@ class DummyDto2
8587
#[Ask('Enter arg7')]
8688
public string $arg7;
8789

90+
#[Argument]
91+
#[Ask('Enter arg8')]
92+
public bool $arg8;
93+
94+
#[Argument]
95+
#[Ask('Enter arg9')]
96+
public array $arg9;
97+
8898
#[Interact]
8999
public function prompt(SymfonyStyle $io): void
90100
{

src/Symfony/Component/Console/Tests/Tester/CommandTesterTest.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -472,7 +472,7 @@ public static function getInvokableWithInputData(): iterable
472472
public function testInvokableWithInteractiveQuestionParameter()
473473
{
474474
$tester = new CommandTester(new InvokableWithInteractiveAttributesTestCommand());
475-
$tester->setInputs(['arg1-value', 'arg2-value', 'arg3-value', 'arg6-value', 'arg7-value', 'arg4-value', 'arg5-value']);
475+
$tester->setInputs(['arg1-value', 'arg2-value', 'arg3-value', 'arg6-value', 'arg7-value', 'yes', 'arg9-v1', 'arg9-v2', '', 'arg4-value', 'arg5-value']);
476476
$tester->execute([], ['interactive' => true]);
477477
$tester->assertCommandIsSuccessful();
478478

@@ -486,6 +486,10 @@ public function testInvokableWithInteractiveQuestionParameter()
486486
self::assertStringContainsString('Arg6: arg6-value', $tester->getDisplay());
487487
self::assertStringContainsString('Enter arg7', $tester->getDisplay());
488488
self::assertStringContainsString('Arg7: arg7-value', $tester->getDisplay());
489+
self::assertStringContainsString('Enter arg8 (yes/no) [no]', $tester->getDisplay());
490+
self::assertStringContainsString('Arg8: yes', $tester->getDisplay());
491+
self::assertStringContainsString('Enter arg9', $tester->getDisplay());
492+
self::assertStringContainsString('Arg9: arg9-v1,arg9-v2', $tester->getDisplay());
489493
self::assertStringContainsString('Enter arg4', $tester->getDisplay());
490494
self::assertStringContainsString('Arg4: arg4-value', $tester->getDisplay());
491495
self::assertStringContainsString('Enter arg5', $tester->getDisplay());

0 commit comments

Comments
 (0)