8000 Add support for invokable commands and input attributes · symfony/symfony@f6347ce · GitHub
[go: up one dir, main page]

Skip to content

Commit f6347ce

Browse files
committed
Add support for invokable commands and input attributes
1 parent 78f4d9a commit f6347ce

File tree

8 files changed

+449
-6
lines changed

8 files changed

+449
-6
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

+12
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
use Symfony\Component\Config\Resource\FileResource;
5050
use Symfony\Component\Config\ResourceCheckerInterface;
5151
use Symfony\Component\Console\Application;
52+
use Symfony\Component\Console\Attribute\AsCommand;
5253
use Symfony\Component\Console\Command\Command;
5354
use Symfony\Component\Console\DataCollector\CommandDataCollector;
5455
use Symfony\Component\Console\Debug\CliRequest;
@@ -611,6 +612,17 @@ public function load(array $configs, ContainerBuilder $container): void
611612
->addTag('asset_mapper.compiler');
612613
$container->registerForAutoconfiguration(Command::class)
613614
->addTag('console.command');
615+
$container->registerAttributeForAutoconfiguration(AsCommand::class, static function (ChildDefinition $definition, AsCommand $attribute, \ReflectionClass $reflector): void {
616+
if ($reflector->isSubclassOf(Command::class)) {
617+
return;
618+
}
619+
620+
if (!$reflector->hasMethod('__invoke')) {
621+
throw new LogicException(\sprintf('The class "%s" must implement the "__invoke()" method to be registered as an invokable command.', $reflector->getName()));
622+
}
623+
624+
$definition->addTag('console.command', ['command' => $attribute->name, 'description' => $attribute->description ?? $reflector->getName(), 'invokable' => true]);
625+
});
614626
$container->registerForAutoconfiguration(ResourceCheckerInterface::class)
615627
->addTag('config_cache.resource_checker');
616628
$container->registerForAutoconfiguration(EnvVarLoaderInterface::class)
< F438 /tr>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Completion\CompletionInput;
15+
use Symfony\Component\Console\Completion\Suggestion;
16+
use Symfony\Component\Console\Exception\LogicException;
17+
use Symfony\Component\Console\Input\InputArgument;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
20+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
21+
class Argument
22+
{
23+
private ?int $mode = null;
24+
25+
/**
26+
* Represents a console command <argument> definition.
27+
*
28+
* If unset, the `name` and `default` values will be inferred from the parameter definition.
29+
*
30+
* @param string|bool|int|float|array|null $default The default value (for InputArgument::OPTIONAL mode only)
31+
* @param array|callable-string(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion
32+
*/
33+
public function __construct(
34+
public string $name = '',
35+
public string $description = '',
36+
public string|bool|int|float|array|null $default = null,
37+
public array|string $suggestedValues = [],
38+
) {
39+
if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) {
40+
throw new \TypeError(\sprintf('Argument 4 passed to "%s()" must be either an array or a callable-string.', __METHOD__));
41+
}
42+
}
43+
44+
public static function tryFrom(\ReflectionParameter $parameter): ?self
45+
{
46+
/** @var self $self */
47+
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
48+
return null;
49+
}
50+
51+
$type = $parameter->getType();
52+
$name = $parameter->getName();
53+
54+
if (!$type instanceof \ReflectionNamedType) {
55+
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name));
56+
}
57+
58+
$parameterTypeName = $type->getName();
59+
60+
if (!\in_array($parameterTypeName, ['string', 'bool', 'int', 'float', 'array'], true)) {
61+
throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command argument. Only "string", "bool", "int", "float", and "array" are allowed.', $parameterTypeName, $name));
62+
}
63+
64+
if (!$self->name) {
65+
$self->name = $name;
66+
}
67+
68+
$self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
69+
if ('array' === $parameterTypeName) {
70+
$self->mode |= InputArgument::IS_ARRAY;
71+
}
72+
73+
$self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
74+
75+
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::class, $self->suggestedValues[1])) {
76+
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
77+
}
78+
79+
return $self;
80+
}
81+
82+
public function toInputArgument(): InputArgument
83+
{
84+
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues;
85+
86+
return new InputArgument($this->name, $this->mode, $this->description, $this->default, $suggestedValues);
87+
}
88+
89+
public function resolveValue(InputInterface $input): mixed
90+
{
91+
return $input->hasArgument($this->name) ? $input->getArgument($this->name) : null;
92+
}
93+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Completion\CompletionInput;
15+
use Symfony\Component\Console\Completion\Suggestion;
16+
use Symfony\Component\Console\Exception\LogicException;
17+
use Symfony\Component\Console\Input\InputInterface;
18+
use Symfony\Component\Console\Input\InputOption;
19+
20+
#[\Attribute(\Attribute::TARGET_PARAMETER)]
21+
class Option
22+
{
23+
private ?int $mode = null;
24+
private string $typeName = '';
25+
26+
/**
27+
* Represents a console command --option definition.
28+
*
29+
* If unset, the `name` and `default` values will be inferred from the parameter definition.
30+
*
31+
* @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts
32+
* @param scalar|array|null $default The default value (must be null for self::VALUE_NONE)
33+
* @param array|callable-string(CompletionInput):list<string|Suggestion> $suggestedValues The values used for input completion
34+
*/
35+
public function __construct(
36+
public string $name = '',
37+
public array|string|null $shortcut = null,
38+
public string $description = '',
39+
public string|bool|int|float|array|null $default = null,
40+
public array|string $suggestedValues = [],
41+
) {
42+
if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) {
43+
throw new \TypeError(\sprintf('Argument 5 passed to "%s()" must be either an array or a callable-string.', __METHOD__));
44+
}
45+
}
46+
47+
public static function tryFrom(\ReflectionParameter $parameter): ?self
48+
{
49+
/** @var self $self */
50+
if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) {
51+
return null;
52+
}
53+
54+
$type = $parameter->getType();
55+
$name = $parameter->getName();
56+
57+
if (!$type instanceof \ReflectionNamedType) {
58+
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command options.', $name));
59+
}
60+
61+
$self->typeName = $type->getName();
62+
63+
if (!\in_array($self->typeName, ['string', 'bool', 'int', 'float', 'array'], true)) {
64+
throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "string", "bool", "int", "float", and "array" are allowed.', $self->typeName, $name));
65+
}
66+
67+
if (!$self->name) {
68+
$self->name = $name;
69+
}
70+
71+
if ('bool' === $self->typeName) {
72+
$self->mode = InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE;
73+
} else {
74+
$self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED;
75+
if ('array' === $self->typeName) {
76+
$self->mode |= InputOption::VALUE_IS_ARRAY;
77+
}
78+
}
79+
80+
if (InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $self->mode)) {
81+
$self->default = null;
82+
} else {
83+
$self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null;
84+
}
85+
86+
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::class, $self->suggestedValues[1])) {
87+
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
88+
}
89+
90+
return $self;
91+
}
92+
93+
public function toInputOption(): InputOption
94+
{
95+
$suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues;
96+
97+
return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestedValues);
98+
}
99+
100+
public function resolveValue(InputInterface $input): mixed
101+
{
102+
if ('bool' === $this->typeName) {
103+
return $input->hasOption($this->name) && null !== $input->getOption($this->name) ? $input->getOption($this->name) : ($this->default ?? false);
104+
}
105+
106+
return $input->hasOption($this->name) ? $input->getOption($this->name) : null;
107+
}
108+
}

src/Symfony/Component/Console/CHANGELOG.md

+5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.3
5+
---
6+
7+
* Add support for invokable commands and introduce `#[Argument]` and `#[Option]` attributes to define command input arguments and options
8+
49
7.2
510
---
611

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

+13-4
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ class Command
4949
private string $description = '';
5050
private ?InputDefinition $fullDefinition = null;
5151
private bool $ignoreValidationErrors = false;
52-
private ?\Closure $code = null;
52+
private ?InvokableCommand $code = null;
5353
private array $synopsis = [];
5454
private array $usages = [];
5555
private ?HelperSet $helperSet = null;
@@ -164,6 +164,9 @@ public function isEnabled(): bool
164164
*/
165165
protected function configure()
166166
{
167+
if (!$this->code && \is_callable($this)) {
168+
$this->code = new InvokableCommand($this, $this(...));
169+
}
167170
}
168171

169172
/**
@@ -274,7 +277,7 @@ public function run(InputInterface $input, OutputInterface $output): int
274277
$input->validate();
275278

276279
if ($this->code) {
277-
$statusCode = ($this->code)($input, $output);
280+
$statusCode = $this->code->invoke($input, $output);
278281
} else {
279282
$statusCode = $this->execute($input, $output);
280283
}
@@ -327,7 +330,7 @@ public function setCode(callable $code): static
327330
$code = $code(...);
328331
}
329332

330-
$this->code = $code;
333+
$this->code = new InvokableCommand($this, $code);
331334

332335
return $this;
333336
}
@@ -395,7 +398,13 @@ public function getDefinition(): InputDefinition
395398
*/
396399
public function getNativeDefinition(): InputDefinition
397400
{
398-
return $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class));
401+
$definition = $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class));
402+
403+
if ($this->code && !$definition->getArguments() && !$definition->getOptions()) {
404+
$this->code->configure($definition);
405+
}
406+
407+
return $definition;
399408
}
400409

401410
/**
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
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\Command;
13+
14+
use Symfony\Component\Console\Attribute\Argument;
15+
use Symfony\Component\Console\Attribute\Option;
16+
use Symfony\Component\Console\Exception\RuntimeException;
17+
use Symfony\Component\Console\Input\InputDefinition;
18+
use Symfony\Component\Console\Input\InputInterface;
19+
use Symfony\Component\Console\Output\OutputInterface;
20+
use Symfony\Component\Console\Style\SymfonyStyle;
21+
22+
/**
23+
* Represents an invokable command.
24+
*
25+
* @author Yonel Ceruto <open@yceruto.dev>
26+
*
27+
* @internal
28+
*/
29+
class InvokableCommand
30+
{
31+
public function __construct(
32+
private readonly Command $command,
33+
private readonly \Closure $code,
34+
) {
35+
}
36+
37+
/**
38+
* Invokes a callable with parameters generated from the input interface.
39+
*/
40+
public function invoke(InputInterface $input, OutputInterface $output): mixed
41+
{
42+
return ($this->code)(...$this->parameters($input, $output));
43+
}
44+
45+
/**
46+
* Configures the input definition from an invokable-defined function.
47+
*
48+
* Processes the parameters of the reflection function to extract and
49+
* add arguments or options to the provided input definition.
50+
*/
51+
public function configure(InputDefinition $definition): void
52+
{
53+
$reflection = new \ReflectionFunction($this->code);
54+
55+
foreach ($reflection->getParameters() as $parameter) {
56+
if ($argument = Argument::tryFrom($parameter)) {
57+
$definition->addArgument($argument->toInputArgument());
58+
} elseif ($option = Option::tryFrom($parameter)) {
59+
$definition->addOption($option->toInputOption());
60+
}
61+
}
62+
}
63+
64+
private function parameters(InputInterface $input, OutputInterface $output): array
65+
{
66+
$parameters = [];
67+
$reflection = new \ReflectionFunction($this->code);
68+
69+
foreach ($reflection->getParameters() as $parameter) {
70+
if ($argument = Argument::tryFrom($parameter)) {
71+
$parameters[] = $argument->resolveValue($input);
72+
73+
continue;
74+
}
75+
76+
if ($option = Option::tryFrom($parameter)) {
77+
$parameters[] = $option->resolveValue($input);
78+
79+
continue;
80+
}
81+
82+
$type = $parameter->getType();
83+
84+
if (!$type instanceof \ReflectionNamedType) {
85+
continue;
86+
}
87+
88+
$parameters[] = match ($type->getName()) {
89+
InputInterface::class => $input,
90+
OutputInterface::class => $output,
91+
SymfonyStyle::class => new SymfonyStyle($input, $output),
92+
Command::class => $this->command,
93+
default => throw new RuntimeException(\sprintf('Unsupported type "%s" for parameter "$%s".', $type->getName(), $parameter->getName())),
94+
};
95+
}
96+
97+
return $parameters ?: [$input, $output];
98+
}
99+
}

0 commit comments

Comments
 (0)
0