Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion src/Symfony/Component/Console/Attribute/Argument.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ class Argument
* @var string|class-string<\BackedEnum>
*/
private string $typeName = '';
private ?InteractiveAttributeInterface $interactiveAttribute = null;

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

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

$self->mode = ($reflection->hasDefaultValue() || $reflection->isNullable()) ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
$isOptional = $reflection->hasDefaultValue() || $reflection->isNullable();
$self->mode = $isOptional ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
if ('array' === $self->typeName) {
$self->mode |= InputArgument::IS_ARRAY;
}
Expand All @@ -92,6 +94,12 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member)
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
}

$self->interactiveAttribute = Ask::tryFrom($member, $self->name);

if ($self->interactiveAttribute && $isOptional) {
throw new LogicException(\sprintf('The %s "$%s" argument of "%s" cannot be both interactive and optional.', $reflection->getMemberName(), $self->name, $reflection->getSourceName()));
}

return $self;
}

Expand All @@ -118,4 +126,12 @@ public function resolveValue(InputInterface $input): mixed

return $value;
}

/**
* @internal
*/
public function getInteractiveAttribute(): ?InteractiveAttributeInterface
{
return $this->interactiveAttribute;
}
}
110 changes: 110 additions & 0 deletions src/Symfony/Component/Console/Attribute/Ask.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Attribute;

use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Question\Question;
use Symfony\Component\Console\Style\SymfonyStyle;

#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
class Ask implements InteractiveAttributeInterface
{
public ?\Closure $validator;
private \Closure $closure;

/**
* @param string $question The question to ask the user
* @param string|bool|int|float|null $default The default answer to return if the user enters nothing
* @param bool $hidden Whether the user response must be hidden or not
* @param bool $multiline Whether the user response should accept newline characters
* @param bool $trimmable Whether the user response must be trimmed or not
* @param int|null $timeout The maximum time the user has to answer the question in seconds
* @param callable|null $validator The validator for the question
* @param int|null $maxAttempts The maximum number of attempts allowed to answer the question.
* Null means an unlimited number of attempts
*/
public function __construct(
public string $question,
public string|bool|int|float|null $default = null,
public bool $hidden = false,
public bool $multiline = false,
public bool $trimmable = true,
public ?int $timeout = null,
?callable $validator = null,
public ?int $maxAttempts = null,
) {
$this->validator = $validator ? $validator(...) : null;
}

/**
* @internal
*/
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member, string $name): ?self
{
$reflection = new ReflectionMember($member);

if (!$self = $reflection->getAttribute(self::class)) {
return null;
}

$self->closure = function (SymfonyStyle $io, InputInterface $input) use ($self, $reflection, $name) {
if (($reflection->isProperty() && isset($this->{$reflection->getName()})) || ($reflection->isParameter() && null !== $input->getArgument($name))) {
return;
}

$question = new Question($self->question, $self->default);
$question->setHidden($self->hidden);
$question->setMultiline($self->multiline);
$question->setTrimmable($self->trimmable);
$question->setTimeout($self->timeout);

if (!$self->validator && $reflection->isProperty()) {
$self->validator = function (mixed $value) use ($reflection): mixed {
return $this->{$reflection->getName()} = $value;
};
}

$question->setValidator($self->validator);
$question->setMaxAttempts($self->maxAttempts);

if ($reflection->isBackedEnumType()) {
/** @var class-string<\BackedEnum> $backedType */
$backedType = $reflection->getType()->getName();
$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())));
}

$value = $io->askQuestion($question);

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

if ($reflection->isProperty()) {
$this->{$reflection->getName()} = $value;
} else {
$input->setArgument($name, $value);
}
};

return $self;
}

/**
* @internal
*/
public function getFunction(object $instance): \ReflectionFunction
{
return new \ReflectionFunction($this->closure->bindTo($instance, $instance::class));
}
}
51 changes: 51 additions & 0 deletions src/Symfony/Component/Console/Attribute/Interact.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Attribute;

use Symfony\Component\Console\Exception\LogicException;

#[\Attribute(\Attribute::TARGET_METHOD)]
class Interact implements InteractiveAttributeInterface
{
private \ReflectionMethod $method;

/**
* @internal
*/
public static function tryFrom(\ReflectionMethod $method): ?self
{
/** @var self|null $self */
if (!$self = ($method->getAttributes(self::class)[0] ?? null)?->newInstance()) {
return null;
}

if (!$method->isPublic() || $method->isStatic()) {
throw new LogicException(\sprintf('The interactive method "%s::%s()" must be public and non-static.', $method->getDeclaringClass()->getName(), $method->getName()));
}

if ('__invoke' === $method->getName()) {
throw new LogicException(\sprintf('The "%s::__invoke()" method cannot be used as an interactive method.', $method->getDeclaringClass()->getName()));
}

$self->method = $method;

return $self;
}

/**
* @internal
*/
public function getFunction(object $instance): \ReflectionFunction
{
return new \ReflectionFunction($this->method->getClosure($instance));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\Component\Console\Attribute;

/**
* @internal
*/
interface InteractiveAttributeInterface
{
public function getFunction(object $instance): \ReflectionFunction;
}
93 changes: 81 additions & 12 deletions src/Symfony/Component/Console/Attribute/MapInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Interaction\Interaction;

/**
* Maps a command input into an object (DTO).
Expand All @@ -28,6 +29,14 @@ final class MapInput

private \ReflectionClass $class;

/**
* @var list<Interact>
*/
private array $interactiveAttributes = [];

/**
* @internal
*/
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
{
$reflection = new ReflectionMember($member);
Expand All @@ -49,46 +58,72 @@ public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member)
$self->class = new \ReflectionClass($class);

foreach ($self->class->getProperties() as $property) {
if (!$property->isPublic() || $property->isStatic()) {
continue;
}

if ($argument = Argument::tryFrom($property)) {
$self->definition[$property->name] = $argument;
continue;
}

if ($option = Option::tryFrom($property)) {
} elseif ($option = Option::tryFrom($property)) {
$self->definition[$property->name] = $option;
continue;
} elseif ($input = self::tryFrom($property)) {
$self->definition[$property->name] = $input;
}

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

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

foreach ($self->class->getMethods() as $method) {
if ($attribute = Interact::tryFrom($method)) {
$self->interactiveAttributes[] = $attribute;
}
}

return $self;
}

/**
* @internal
*/
public function resolveValue(InputInterface $input): mixed
public function resolveValue(InputInterface $input): object
{
$instance = $this->class->newInstanceWithoutConstructor();

foreach ($this->definition as $name => $spec) {
// ignore required arguments that are not set yet (may happen in interactive mode)
if ($spec instanceof Argument && null === $input->getArgument($spec->name) && $spec->toInputArgument()->isRequired()) {
continue;
}

$instance->$name = $spec->resolveValue($input);
}

return $instance;
}

/**
* @internal
*/
public function setValue(InputInterface $input, object $object): void
{
foreach ($this->definition as $name => $spec) {
$property = $this->class->getProperty($name);

if (!$property->isInitialized($object) || null === $value = $property->getValue($object)) {
continue;
}

match (true) {
$spec instanceof Argument => $input->setArgument($spec->name, $value),
$spec instanceof Option => $input->setOption($spec->name, $value),
$spec instanceof self => $spec->setValue($input, $value),
default => throw new LogicException('Unexpected specification type.'),
};
}
}

/**
* @return iterable<Argument>
*/
Expand Down Expand Up @@ -116,4 +151,38 @@ public function getOptions(): iterable
}
}
}

/**
* @internal
*
* @return iterable<Interaction>
*/
public function getPropertyInteractions(): iterable
{
foreach ($this->definition as $spec) {
if ($spec instanceof self) {
yield from $spec->getPropertyInteractions();
} elseif ($spec instanceof Argument && $attribute = $spec->getInteractiveAttribute()) {
yield new Interaction($this, $attribute);
}
}
}

/**
* @internal
*
* @return iterable<Interaction>
*/
public function getMethodInteractions(): iterable
{
foreach ($this->definition as $spec) {
if ($spec instanceof self) {
yield from $spec->getMethodInteractions();
}
}

foreach ($this->interactiveAttributes as $attribute) {
yield new Interaction($this, $attribute);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -96,4 +96,19 @@ public function getMemberName(): string
{
return $this->member instanceof \ReflectionParameter ? 'parameter' : 'property';
}

public function isBackedEnumType(): bool
{
return $this->member->getType() instanceof \ReflectionNamedType && is_subclass_of($this->member->getType()->getName(), \BackedEnum::class);
}

public function isParameter(): bool
{
return $this->member instanceof \ReflectionParameter;
}

public function isProperty(): bool
{
return $this->member instanceof \ReflectionProperty;
}
}
Loading
Loading