Skip to content

Commit 14e8332

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

File tree

8 files changed

+449
-6
lines changed

8 files changed

+449
-6
lines changed

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

Lines changed: 12 additions & 0 deletions
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)
Lines changed: 93 additions & 0 deletions
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, $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+
}
Lines changed: 108 additions & 0 deletions
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, $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

Lines changed: 5 additions & 0 deletions
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

Lines changed: 13 additions & 4 deletions
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
/**
Lines changed: 99 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy