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
40 changes: 18 additions & 22 deletions src/Symfony/Component/Console/Attribute/Argument.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\Console\Attribute;

use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\Suggestion;
use Symfony\Component\Console\Exception\InvalidArgumentException;
Expand All @@ -19,15 +20,17 @@
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\String\UnicodeString;

#[\Attribute(\Attribute::TARGET_PARAMETER)]
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
class Argument
{
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];

private string|bool|int|float|array|null $default = null;
private array|\Closure $suggestedValues;
private ?int $mode = null;
private string $function = '';
/**
* @var string|class-string<\BackedEnum>
*/
private string $typeName = '';

/**
Expand All @@ -48,52 +51,45 @@ public function __construct(
/**
* @internal
*/
public static function tryFrom(\ReflectionParameter $parameter): ?self
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
{
/** @var self $self */
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
return null;
}
$reflection = new ReflectionMember($member);

if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) {
$self->function = $function->class.'::'.$function->name;
} else {
$self->function = $function->name;
if (!$self = $reflection->getAttribute(self::class)) {
return null;
}

$type = $parameter->getType();
$name = $parameter->getName();
$type = $reflection->getType();
$name = $reflection->getName();

if (!$type instanceof \ReflectionNamedType) {
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));
throw new LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $reflection->getMemberName(), $name, $reflection->getSourceName()));
}

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

if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) {
throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types and backed enums are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES)));
throw new LogicException(\sprintf('The type "%s" on %s "$%s" of "%s" is not supported as a command argument. Only "%s" types and backed enums are allowed.', $self->typeName, $reflection->getMemberName(), $name, $reflection->getSourceName(), implode('", "', self::ALLOWED_TYPES)));
}

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

if ($parameter->isDefaultValueAvailable()) {
$self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue();
}
$self->default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null;

$self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
$self->mode = ($reflection->hasDefaultValue() || $reflection->isNullable()) ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
if ('array' === $self->typeName) {
$self->mode |= InputArgument::IS_ARRAY;
}

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]])) {
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $reflection->getSourceThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
}

if ($isBackedEnum && !$self->suggestedValues) {
$self->suggestedValues = array_column(($self->typeName)::cases(), 'value');
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
}

return $self;
Expand All @@ -117,7 +113,7 @@ public function resolveValue(InputInterface $input): mixed
$value = $input->getArgument($this->name);

if (is_subclass_of($this->typeName, \BackedEnum::class) && (\is_string($value) || \is_int($value))) {
return ($this->typeName)::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues);
return $this->typeName::tryFrom($value) ?? throw InvalidArgumentException::fromEnumValue($this->name, $value, $this->suggestedValues);
}

return $value;
Expand Down
116 changes: 116 additions & 0 deletions src/Symfony/Component/Console/Attribute/Input.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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\LogicException;
use Symfony\Component\Console\Input\InputInterface;

#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
final class Input
{
/**
* @var array<string, Argument|Option|self>
*/
private array $definition = [];

private \ReflectionClass $class;

public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
{
$reflection = new ReflectionMember($member);

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

$type = $reflection->getType();

if (!$type instanceof \ReflectionNamedType) {
throw new LogicException(\sprintf('The input %s "%s" must have a named type.', $reflection->getMemberName(), $member->name));
}

if (!class_exists($class = $type->getName())) {
throw new LogicException(\sprintf('The input class "%s" does not exist.', $type->getName()));
}

$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)) {
$self->definition[$property->name] = $option;
continue;
}

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

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

return $self;
}

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

foreach ($this->definition as $name => $spec) {
$instance->$name = $spec->resolveValue($input);
}

return $instance;
}

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

/**
* @return iterable<Option>
*/
public function getOptions(): iterable
{
foreach ($this->definition as $spec) {
if ($spec instanceof Option) {
yield $spec;
} elseif ($spec instanceof self) {
yield from $spec->getOptions();
}
}
}
}
53 changes: 28 additions & 25 deletions src/Symfony/Component/Console/Attribute/Option.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

namespace Symfony\Component\Console\Attribute;

use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\Suggestion;
use Symfony\Component\Console\Exception\InvalidOptionException;
Expand All @@ -19,7 +20,7 @@
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\String\UnicodeString;

#[\Attribute(\Attribute::TARGET_PARAMETER)]
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
class Option
{
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
Expand All @@ -28,9 +29,13 @@ class Option
private string|bool|int|float|array|null $default = null;
private array|\Closure $suggestedValues;
private ?int $mode = null;
/**
* @var string|class-string<\BackedEnum>
*/
private string $typeName = '';
private bool $allowNull = false;
private string $function = '';
private string $memberName = '';
private string $sourceName = '';

/**
* Represents a console command --option definition.
Expand All @@ -52,54 +57,52 @@ public function __construct(
/**
* @internal
*/
public static function tryFrom(\ReflectionParameter $parameter): ?self
public static function tryFrom(\ReflectionParameter|\ReflectionProperty $member): ?self
{
/** @var self $self */
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
$reflection = new ReflectionMember($member);

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

if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) {
$self->function = $function->class.'::'.$function->name;
} else {
$self->function = $function->name;
}
$self->memberName = $reflection->getMemberName();
$self->sourceName = $reflection->getSourceName();

$name = $parameter->getName();
$type = $parameter->getType();
$name = $reflection->getName();
$type = $reflection->getType();

if (!$parameter->isDefaultValueAvailable()) {
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must declare a default value.', $name, $self->function));
if (!$reflection->hasDefaultValue()) {
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must declare a default value.', $self->memberName, $name, $self->sourceName));
}

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

$self->default = $parameter->getDefaultValue() instanceof \BackedEnum ? $parameter->getDefaultValue()->value : $parameter->getDefaultValue();
$self->allowNull = $parameter->allowsNull();
$self->default = $reflection->getDefaultValue();
$self->allowNull = $reflection->isNullable();

if ($type instanceof \ReflectionUnionType) {
return $self->handleUnion($type);
}

if (!$type instanceof \ReflectionNamedType) {
throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped or Intersection types are not supported for command options.', $name, $self->function));
throw new LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped or Intersection types are not supported for command options.', $self->memberName, $name, $self->sourceName));
}

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

if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) {
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)));
throw new LogicException(\sprintf('The type "%s" on %s "$%s" of "%s" is not supported as a command option. Only "%s" types and BackedEnum are allowed.', $self->typeName, $self->memberName, $name, $self->sourceName, implode('", "', self::ALLOWED_TYPES)));
}

if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) {
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must not be nullable when it has a default boolean value.', $name, $self->function));
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must not be nullable when it has a default boolean value.', $self->memberName, $name, $self->sourceName));
}

if ($self->allowNull && null !== $self->default) {
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must either be not-nullable or have a default of null.', $name, $self->function));
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must either be not-nullable or have a default of null.', $self->memberName, $name, $self->sourceName));
}

if ('bool' === $self->typeName) {
Expand All @@ -113,12 +116,12 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self
$self->mode = InputOption::VALUE_REQUIRED;
}

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]])) {
if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $reflection->getSourceThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) {
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
}

if ($isBackedEnum && !$self->suggestedValues) {
$self->suggestedValues = array_column(($self->typeName)::cases(), 'value');
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
}

return $self;
Expand Down Expand Up @@ -147,7 +150,7 @@ public function resolveValue(InputInterface $input): mixed
}

if (is_subclass_of($this->typeName, \BackedEnum::class) && (\is_string($value) || \is_int($value))) {
return ($this->typeName)::tryFrom($value) ?? throw InvalidOptionException::fromEnumValue($this->name, $value, $this->suggestedValues);
return $this->typeName::tryFrom($value) ?? throw InvalidOptionException::fromEnumValue($this->name, $value, $this->suggestedValues);
}

if ('array' === $this->typeName && $this->allowNull && [] === $value) {
Expand Down Expand Up @@ -177,11 +180,11 @@ private function handleUnion(\ReflectionUnionType $type): self
$this->typeName = implode('|', array_filter($types));

if (!\in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) {
throw new LogicException(\sprintf('The union type for parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types are allowed.', $this->name, $this->function, implode('", "', self::ALLOWED_UNION_TYPES)));
throw new LogicException(\sprintf('The union type for %s "$%s" of "%s" is not supported as a command option. Only "%s" types are allowed.', $this->memberName, $this->name, $this->sourceName, implode('", "', self::ALLOWED_UNION_TYPES)));
}

if (false !== $this->default) {
throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must have a default value of false.', $this->name, $this->function));
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must have a default value of false.', $this->memberName, $this->name, $this->sourceName));
}

$this->mode = InputOption::VALUE_OPTIONAL;
Expand Down
Loading
Loading