diff --git a/Application.php b/Application.php index 1ea644df0..087fc0736 100644 --- a/Application.php +++ b/Application.php @@ -65,7 +65,7 @@ * Usage: * * $app = new Application('myapp', '1.0 (stable)'); - * $app->add(new SimpleCommand()); + * $app->addCommand(new SimpleCommand()); * $app->run(); * * @author Fabien Potencier @@ -530,7 +530,7 @@ public function getLongVersion(): string */ public function register(string $name): Command { - return $this->add(new Command($name)); + return $this->addCommand(new Command($name)); } /** @@ -538,25 +538,39 @@ public function register(string $name): Command * * If a Command is not enabled it will not be added. * - * @param Command[] $commands An array of commands + * @param callable[]|Command[] $commands An array of commands */ public function addCommands(array $commands): void { foreach ($commands as $command) { - $this->add($command); + $this->addCommand($command); } } + /** + * @deprecated since Symfony 7.4, use Application::addCommand() instead + */ + public function add(Command $command): ?Command + { + trigger_deprecation('symfony/console', '7.4', 'The "%s()" method is deprecated and will be removed in Symfony 8.0, use "%s::addCommand()" instead.', __METHOD__, self::class); + + return $this->addCommand($command); + } + /** * Adds a command object. * * If a command with the same name already exists, it will be overridden. * If the command is not enabled it will not be added. */ - public function add(Command $command): ?Command + public function addCommand(callable|Command $command): ?Command { $this->init(); + if (!$command instanceof Command) { + $command = new Command(null, $command); + } + $command->setApplication($this); if (!$command->isEnabled()) { @@ -622,7 +636,7 @@ public function has(string $name): bool { $this->init(); - return isset($this->commands[$name]) || ($this->commandLoader?->has($name) && $this->add($this->commandLoader->get($name))); + return isset($this->commands[$name]) || ($this->commandLoader?->has($name) && $this->addCommand($this->commandLoader->get($name))); } /** @@ -1339,8 +1353,14 @@ private function init(): void } $this->initialized = true; + if ((new \ReflectionMethod($this, 'add'))->getDeclaringClass()->getName() !== (new \ReflectionMethod($this, 'addCommand'))->getDeclaringClass()->getName()) { + $adder = $this->add(...); + } else { + $adder = $this->addCommand(...); + } + foreach ($this->getDefaultCommands() as $command) { - $this->add($command); + $adder($command); } } } diff --git a/Attribute/Argument.php b/Attribute/Argument.php index e6a94d2f1..b1b40bd3e 100644 --- a/Attribute/Argument.php +++ b/Attribute/Argument.php @@ -11,14 +11,16 @@ 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; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Input\InputArgument; 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']; @@ -26,7 +28,10 @@ class Argument 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 = ''; /** * Represents a console command definition. @@ -46,47 +51,47 @@ 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())); } - $parameterTypeName = $type->getName(); + $self->typeName = $type->getName(); + $isBackedEnum = is_subclass_of($self->typeName, \BackedEnum::class); - if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) { - throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES))); + if (!\in_array($self->typeName, self::ALLOWED_TYPES, true) && !$isBackedEnum) { + 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(); } - $self->default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + $self->default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null; - $self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; - if ('array' === $parameterTypeName) { + $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'); + } + return $self; } @@ -105,6 +110,12 @@ public function toInputArgument(): InputArgument */ public function resolveValue(InputInterface $input): mixed { - return $input->getArgument($this->name); + $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 $value; } } diff --git a/Attribute/AsCommand.php b/Attribute/AsCommand.php index 767d46ebb..02f156201 100644 --- a/Attribute/AsCommand.php +++ b/Attribute/AsCommand.php @@ -25,6 +25,7 @@ class AsCommand * @param string[] $aliases The list of aliases of the command. The command will be executed when using one of them (i.e. "cache:clean") * @param bool $hidden If true, the command won't be shown when listing all the available commands, but it can still be run as any other command * @param string|null $help The help content of the command, displayed with the help page + * @param string[] $usages The list of usage examples, displayed with the help page */ public function __construct( public string $name, @@ -32,6 +33,7 @@ public function __construct( array $aliases = [], bool $hidden = false, public ?string $help = null, + public array $usages = [], ) { if (!$hidden && !$aliases) { return; diff --git a/Attribute/Input.php b/Attribute/Input.php new file mode 100644 index 000000000..65bbf4aef --- /dev/null +++ b/Attribute/Input.php @@ -0,0 +1,116 @@ + + * + * 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 + */ + 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 + */ + 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