Skip to content

Commit fd86d3f

Browse files
committed
[Console] Add support for interactive invokable commands with #[Interactive] and #[InteractiveQuestion] attribute
1 parent 9fd4503 commit fd86d3f

File tree

12 files changed

+532
-22
lines changed

12 files changed

+532
-22
lines changed

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ class Argument
3232
* @var string|class-string<\BackedEnum>
3333
*/
3434
private string $typeName = '';
35+
private ?InteractiveQuestion $interactiveQuestion = null;
3536

3637
/**
3738
* Represents a console command <argument> definition.
@@ -79,7 +80,8 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member)
7980

8081
$self->default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null;
8182

82-
$self->mode = ($reflection->hasDefaultValue() || $reflection->isNullable()) ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
83+
$isOptional = $reflection->hasDefaultValue() || $reflection->isNullable();
84+
$self->mode = $isOptional ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
8385
if ('array' === $self->typeName) {
8486
$self->mode |= InputArgument::IS_ARRAY;
8587
}
@@ -92,6 +94,10 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member)
9294
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
9395
}
9496

97+
if (($self->interactiveQuestion = InteractiveQuestion::tryFrom($member, $self->name)) && $isOptional) {
98+
throw new LogicException(\sprintf('The %s "$%s" of "%s" cannot be both interactive and optional.', $reflection->getMemberName(), $self->name, $reflection->getSourceName()));
99+
}
100+
95101
return $self;
96102
}
97103

@@ -118,4 +124,9 @@ public function resolveValue(InputInterface $input): mixed
118124

119125
return $value;
120126
}
127+
128+
public function getInteractiveQuestion(): ?InteractiveQuestion
129+
{
130+
return $this->interactiveQuestion;
131+
}
121132
}

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

Lines changed: 77 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
1515
use Symfony\Component\Console\Exception\LogicException;
1616
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Interaction\Interaction;
1718

1819
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
1920
final class Input
@@ -25,6 +26,14 @@ final class Input
2526

2627
private \ReflectionClass $class;
2728

29+
/**
30+
* @var array<Interactive>
31+
*/
32+
private array $interactiveAttributes = [];
33+
34+
/**
35+
* @internal
36+
*/
2837
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
2938
{
3039
$reflection = new ReflectionMember($member);
@@ -46,46 +55,72 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member)
4655
$self->class = new \ReflectionClass($class);
4756

4857
foreach ($self->class->getProperties() as $property) {
49-
if (!$property->isPublic() || $property->isStatic()) {
50-
continue;
51-
}
52-
5358
if ($argument = Argument::tryFrom($property)) {
5459
$self->definition[$property->name] = $argument;
55-
continue;
56-
}
57-
58-
if ($option = Option::tryFrom($property)) {
60+
} elseif ($option = Option::tryFrom($property)) {
5961
$self->definition[$property->name] = $option;
60-
continue;
62+
} elseif ($input = self::tryFrom($property)) {
63+
$self->definition[$property->name] = $input;
6164
}
6265

63-
if ($input = self::tryFrom($property)) {
64-
$self->definition[$property->name] = $input;
66+
if (isset($self->definition[$property->name]) && (!$property->isPublic() || $property->isStatic())) {
67+
throw new LogicException(\sprintf('The input property "%s::$%s" must be public and non-static.', $self->class->name, $property->name));
6568
}
6669
}
6770

6871
if (!$self->definition) {
6972
throw new LogicException(\sprintf('The input class "%s" must have at least one argument or option.', $self->class->name));
7073
}
7174

75+
foreach ($self->class->getMethods() as $method) {
76+
if ($interactive = Interactive::tryFrom($method)) {
77+
$self->interactiveAttributes[] = $interactive;
78+
}
79+
}
80+
7281
return $self;
7382
}
7483

7584
/**
7685
* @internal
7786
*/
78-
public function resolveValue(InputInterface $input): mixed
87+
public function resolveValue(InputInterface $input): object
7988
{
8089
$instance = $this->class->newInstanceWithoutConstructor();
8190

8291
foreach ($this->definition as $name => $spec) {
92+
// ignore required arguments that are not set yet (may happen in interactive mode)
93+
if ($spec instanceof Argument && null === $input->getArgument($spec->name) && $spec->toInputArgument()->isRequired()) {
94+
continue;
95+
}
96+
8397
$instance->$name = $spec->resolveValue($input);
8498
}
8599

86100
return $instance;
87101
}
88102

103+
/**
104+
* @internal
105+
*/
106+
public function setValue(InputInterface $input, object $object): void
107+
{
108+
foreach ($this->definition as $name => $spec) {
109+
$property = $this->class->getProperty($name);
110+
111+
if (!$property->isInitialized($object) || null === $value = $property->getValue($object)) {
112+
continue;
113+
}
114+
115+
match (true) {
116+
$spec instanceof Argument => $input->setArgument($spec->name, $value),
117+
$spec instanceof Option => $input->setOption($spec->name, $value),
118+
$spec instanceof self => $spec->setValue($input, $value),
119+
default => throw new LogicException('Unexpected specification type.'),
120+
};
121+
}
122+
}
123+
89124
/**
90125
* @return iterable<Argument>
91126
*/
@@ -113,4 +148,34 @@ public function getOptions(): iterable
113148
}
114149
}
115150
}
151+
152+
/**
153+
* @return iterable<Interaction>
154+
*/
155+
public function getPropertyInteractions(): iterable
156+
{
157+
foreach ($this->definition as $spec) {
158+
if ($spec instanceof self) {
159+
yield from $spec->getPropertyInteractions();
160+
} elseif ($spec instanceof Argument && $interactiveQuestion = $spec->getInteractiveQuestion()) {
161+
yield new Interaction($this, $interactiveQuestion);
162+
}
163+
}
164+
}
165+
166+
/**
167+
* @return iterable<Interaction>
168+
*/
169+
public function getMethodInteractions(): iterable
170+
{
171+
foreach ($this->definition as $spec) {
172+
if ($spec instanceof self) {
173+
yield from $spec->getMethodInteractions();
174+
}
175+
}
176+
177+
foreach ($this->interactiveAttributes as $interactive) {
178+
yield new Interaction($this, $interactive);
179+
}
180+
}
116181
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Attribute;
13+
14+
use Symfony\Component\Console\Exception\LogicException;
15+
16+
#[\Attribute(\Attribute::TARGET_METHOD)]
17+
class Interactive
18+
{
19+
private \ReflectionMethod $method;
20+
21+
/**
22+
* @internal
23+
*/
24+
public static function tryFrom(\ReflectionMethod $method): ?self
25+
{
26+
/** @var self|null $self */
27+
if (!$self = ($method->getAttributes(self::class)[0] ?? null)?->newInstance()) {
28+
return null;
29+
}
30+
31+
if (!$method->isPublic() || $method->isStatic()) {
32+
throw new LogicException(\sprintf('The interactive method "%s::%s()" must be public and non-static.', $method->getDeclaringClass()->getName(), $method->getName()));
33+
}
34+
35+
if ('__invoke' === $method->getName()) {
36+
throw new LogicException(\sprintf('The "%s::__invoke()" method cannot be used as an interactive method.', $method->getDeclaringClass()->getName()));
37+
}
38+
39+
$self->method = $method;
40+
41+
return $self;
42+
}
43+
44+
public function getFunction(object $instance): \ReflectionFunction
45+
{
46+
return new \ReflectionFunction($this->method->getClosure($instance));
47+
}
48+
}
Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Console\Attribute;
13+
14+
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
15+
use Symfony\Component\Console\Exception\InvalidArgumentException;
16+
use Symfony\Component\Console\Input\InputInterface;
17+
use Symfony\Component\Console\Question\Question;
18+
use Symfony\Component\Console\Style\SymfonyStyle;
19+
20+
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
21+
class InteractiveQuestion
22+
{
23+
public ?\Closure $validator;
24+
private \Closure $closure;
25+
26+
/**
27+
* @param string $question The question to ask the user
28+
* @param string|bool|int|float|null $default The default answer to return if the user enters nothing
29+
* @param bool $hidden Whether the user response must be hidden or not
30+
* @param bool $multiline Whether the user response should accept newline characters
31+
* @param bool $trimmable Whether the user response must be trimmed or not
32+
* @param int|null $timeout The maximum time the user has to answer the question in seconds
33+
* @param callable|null $validator The validator for the question
34+
* @param int|null $maxAttempts The maximum number of attempts allowed to answer the question.
35+
* Null means an unlimited number of attempts
36+
*/
37+
public function __construct(
38+
public string $question,
39+
public string|bool|int|float|null $default = null,
40+
public bool $hidden = false,
41+
public bool $multiline = false,
42+
public bool $trimmable = true,
43+
public ?int $timeout = null,
44+
?callable $validator = null,
45+
public ?int $maxAttempts = null,
46+
) {
47+
$this->validator = $validator ? $validator(...) : null;
48+
}
49+
50+
/**
51+
* @internal
52+
*/
53+
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member, string $name): ?self
54+
{
55+
$reflection = new ReflectionMember($member);
56+
57+
if (!$self = $reflection->getAttribute(self::class)) {
58+
return null;
59+
}
60+
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))) {
63+
return;
64+
}
65+
66+
$question = new Question($self->question, $self->default);
67+
$question->setHidden($self->hidden);
68+
$question->setMultiline($self->multiline);
69+
$question->setTrimmable($self->trimmable);
70+
$question->setTimeout($self->timeout);
71+
72+
if (!$self->validator && $reflection->isProperty()) {
73+
$self->validator = function (mixed $value) use ($reflection): mixed {
74+
return $this->{$reflection->getName()} = $value;
75+
};
76+
}
77+
78+
$question->setValidator($self->validator);
79+
$question->setMaxAttempts($self->maxAttempts);
80+
81+
if ($reflection->isBackedEnumType()) {
82+
/** @var class-string<\BackedEnum> $backedType */
83+
$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())));
85+
}
86+
87+
$value = $io->askQuestion($question);
88+
89+
if (null === $value && !$reflection->isNullable()) {
90+
return;
91+
}
92+
93+
if ($reflection->isProperty()) {
94+
$this->{$reflection->getName()} = $value;
95+
} else {
96+
$input->setArgument($name, $value);
97+
}
98+
};
99+
100+
return $self;
101+
}
102+
103+
public function getFunction(object $instance): \ReflectionFunction
104+
{
105+
return new \ReflectionFunction($this->closure->bindTo($instance, $instance::class));
106+
}
107+
}

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,19 @@ public function getMemberName(): string
9696
{
9797
return $this->member instanceof \ReflectionParameter ? 'parameter' : 'property';
9898
}
99+
100+
public function isBackedEnumType(): bool
101+
{
102+
return $this->member->getType() instanceof \ReflectionNamedType && is_subclass_of($this->member->getType()->getName(), \BackedEnum::class);
103+
}
104+
105+
public function isParameter(): bool
106+
{
107+
return $this->member instanceof \ReflectionParameter;
108+
}
109+
110+
public function isProperty(): bool
111+
{
112+
return $this->member instanceof \ReflectionProperty;
113+
}
99114
}

src/Symfony/Component/Console/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ CHANGELOG
1313
* Allow passing invokable commands to `Symfony\Component\Console\Tester\CommandTester`
1414
* Add `#[Input]` attribute to support DTOs in commands
1515
* Add optional timeout for interaction in `QuestionHelper`
16+
* Add support for interactive invokable commands with `#[Interactive]` and `#[InteractiveQuestion]` attributes
1617

1718
7.3
1819
---

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,10 @@ public function run(InputInterface $input, OutputInterface $output): int
313313

314314
if ($input->isInteractive()) {
315315
$this->interact($input, $output);
316+
317+
if ($this->code?->isInteractive()) {
318+
$this->code?->interact($input, $output);
319+
}
316320
}
317321

318322
// The command name argument is often omitted when a command is executed directly with its run() method.

0 commit comments

Comments
 (0)