[Console] Add argument resolvers

This commit is contained in:
Robin Chalas
2025-10-13 13:23:30 +02:00
parent ccae82871f
commit 6a35416845
38 changed files with 3604 additions and 152 deletions

View File

@@ -11,6 +11,7 @@
namespace Symfony\Component\Console;
use Symfony\Component\Console\ArgumentResolver\ArgumentResolverInterface;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Command\CompleteCommand;
use Symfony\Component\Console\Command\DumpCompletionCommand;
@@ -82,6 +83,7 @@ class Application implements ResetInterface
private InputDefinition $definition;
private HelperSet $helperSet;
private ?EventDispatcherInterface $dispatcher = null;
private ?ArgumentResolverInterface $argumentResolver = null;
private Terminal $terminal;
private string $defaultCommand;
private bool $singleCommand = false;
@@ -110,6 +112,19 @@ class Application implements ResetInterface
$this->dispatcher = $dispatcher;
}
/**
* @final
*/
public function setArgumentResolver(ArgumentResolverInterface $argumentResolver): void
{
$this->argumentResolver = $argumentResolver;
}
public function getArgumentResolver(): ?ArgumentResolverInterface
{
return $this->argumentResolver;
}
public function setCommandLoader(CommandLoaderInterface $commandLoader): void
{
$this->commandLoader = $commandLoader;

View File

@@ -0,0 +1,159 @@
<?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\ArgumentResolver;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\ArgumentResolver\Exception\NearMissValueResolverException;
use Symfony\Component\Console\ArgumentResolver\Exception\ResolverNotFoundException;
use Symfony\Component\Console\ArgumentResolver\ValueResolver as Resolver;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\ValueResolverInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Attribute\ValueResolver;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Cursor;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Contracts\Service\ServiceProviderInterface;
/**
* Resolves the arguments passed to a console command.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class ArgumentResolver implements ArgumentResolverInterface
{
/**
* @param iterable<mixed, ValueResolverInterface> $argumentValueResolvers
*/
public function __construct(
private iterable $argumentValueResolvers = [],
private ?ContainerInterface $namedResolvers = null,
) {
}
public function getArguments(InputInterface $input, callable $command, ?\ReflectionFunctionAbstract $reflector = null): array
{
$reflector ??= new \ReflectionFunction($command(...));
$argumentReflectors = [];
foreach ($reflector->getParameters() as $param) {
$argumentReflectors[$param->getName()] = new ReflectionMember($param);
}
$arguments = [];
foreach ($argumentReflectors as $argumentName => $member) {
$argumentValueResolvers = $this->argumentValueResolvers;
$disabledResolvers = [];
if ($this->namedResolvers && $attributes = $member->getAttributes(ValueResolver::class)) {
$resolverName = null;
foreach ($attributes as $attribute) {
if ($attribute->disabled) {
$disabledResolvers[$attribute->resolver] = true;
} elseif ($resolverName) {
throw new \LogicException(\sprintf('You can only pin one resolver per argument, but argument "$%s" of "%s()" has more.', $member->getName(), $member->getSourceName()));
} else {
$resolverName = $attribute->resolver;
}
}
if ($resolverName) {
if (!$this->namedResolvers->has($resolverName)) {
throw new ResolverNotFoundException($resolverName, $this->namedResolvers instanceof ServiceProviderInterface ? array_keys($this->namedResolvers->getProvidedServices()) : []);
}
$argumentValueResolvers = [
$this->namedResolvers->get($resolverName),
];
}
}
$valueResolverExceptions = [];
foreach ($argumentValueResolvers as $name => $resolver) {
if (isset($disabledResolvers[\is_int($name) ? $resolver::class : $name])) {
continue;
}
try {
$count = 0;
foreach ($resolver->resolve($argumentName, $input, $member) as $argument) {
++$count;
$arguments[] = $argument;
}
} catch (NearMissValueResolverException $e) {
$valueResolverExceptions[] = $e;
}
if (1 < $count && !$member->isVariadic()) {
throw new \InvalidArgumentException(\sprintf('"%s::resolve()" must yield at most one value for non-variadic arguments.', get_debug_type($resolver)));
}
if ($count) {
continue 2;
}
}
// For variadic parameters with explicit input mapping, 0 values is valid
if ($member->isVariadic() && (Argument::tryFrom($member->getMember()) || Option::tryFrom($member->getMember()))) {
continue;
}
$type = $member->getType();
$typeName = $type instanceof \ReflectionNamedType ? $type->getName() : null;
if ($typeName && \in_array($typeName, [
InputInterface::class,
OutputInterface::class,
SymfonyStyle::class,
Cursor::class,
\Symfony\Component\Console\Application::class,
Command::class,
], true)) {
continue;
}
$reasons = array_map(static fn (NearMissValueResolverException $e) => $e->getMessage(), $valueResolverExceptions);
if (!$reasons) {
$reasons[] = \sprintf('The parameter has no #[Argument], #[Option], or #[MapInput] attribute, and its type "%s" cannot be auto-resolved.', $typeName ?? 'unknown');
$reasons[] = 'Add an attribute to map this parameter to command input.';
}
throw new \RuntimeException(\sprintf('Could not resolve parameter "$%s" of command "%s".'."\n\n".'Possible reasons:'."\n".' • '.implode("\n", $reasons), $member->getName(), $member->getSourceName()));
}
return $arguments;
}
/**
* @return iterable<int, ValueResolverInterface>
*/
public static function getDefaultArgumentValueResolvers(): iterable
{
$builtinTypeResolver = new Resolver\BuiltinTypeValueResolver();
$backedEnumResolver = new Resolver\BackedEnumValueResolver();
$dateTimeResolver = new Resolver\DateTimeValueResolver();
return [
$backedEnumResolver,
new Resolver\UidValueResolver(),
$builtinTypeResolver,
new Resolver\MapInputValueResolver($builtinTypeResolver, $backedEnumResolver, $dateTimeResolver),
$dateTimeResolver,
new Resolver\DefaultValueResolver(),
new Resolver\VariadicValueResolver(),
];
}
}

View File

@@ -0,0 +1,33 @@
<?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\ArgumentResolver;
use Symfony\Component\Console\ArgumentResolver\Exception\ResolverNotFoundException;
use Symfony\Component\Console\Input\InputInterface;
/**
* Determines the arguments for a specific Console Command.
*
* @author Robin Chalas <robin.chalas@gmail.com>
* @author Fabien Potencier <fabien@symfony.com>
* @author Nicolas Grekas <p@tchwork.com>
*/
interface ArgumentResolverInterface
{
/**
* Returns the arguments to pass to the Console Command after resolution.
*
* @throws \RuntimeException When no value could be provided for a required argument
* @throws ResolverNotFoundException
*/
public function getArguments(InputInterface $input, callable $command, ?\ReflectionFunctionAbstract $reflector = null): array;
}

View File

@@ -0,0 +1,21 @@
<?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\ArgumentResolver\Exception;
/**
* Lets value resolvers tell when an argument could be under their watch but failed to be resolved.
*
* Throwing this exception inside `ValueResolverInterface::resolve` does not interrupt the value resolvers chain.
*/
final class NearMissValueResolverException extends \RuntimeException
{
}

View File

@@ -0,0 +1,33 @@
<?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\ArgumentResolver\Exception;
final class ResolverNotFoundException extends \RuntimeException
{
/**
* @param string[] $alternatives
*/
public function __construct(string $name, array $alternatives = [])
{
$msg = \sprintf('You have requested a non-existent resolver "%s".', $name);
if ($alternatives) {
if (1 === \count($alternatives)) {
$msg .= ' Did you mean this: "';
} else {
$msg .= ' Did you mean one of these: "';
}
$msg .= implode('", "', $alternatives).'"?';
}
parent::__construct($msg);
}
}

View File

@@ -0,0 +1,90 @@
<?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\ArgumentResolver\ValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\InputInterface;
/**
* Resolves a BackedEnum instance from a Command argument or option.
*
* @author Robin Chalas <robin.chalas@gmail.com>
* @author Jérôme Tamarelle <jerome@tamarelle.net>
* @author Maxime Steinhausser <maxime.steinhausser@gmail.com>
*/
final class BackedEnumValueResolver implements ValueResolverInterface
{
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
if ($argument = Argument::tryFrom($member->getMember())) {
if (!is_subclass_of($argument->typeName, \BackedEnum::class)) {
return [];
}
return [$this->resolveArgument($argument, $input)];
}
if ($option = Option::tryFrom($member->getMember())) {
if (!is_subclass_of($option->typeName, \BackedEnum::class)) {
return [];
}
return [$this->resolveOption($option, $input)];
}
return [];
}
private function resolveArgument(Argument $argument, InputInterface $input): ?\BackedEnum
{
$value = $input->getArgument($argument->name);
if (null === $value) {
return null;
}
if ($value instanceof $argument->typeName) {
return $value;
}
if (!\is_string($value) && !\is_int($value)) {
throw InvalidArgumentException::fromEnumValue($argument->name, get_debug_type($value), $argument->suggestedValues);
}
return $argument->typeName::tryFrom($value)
?? throw InvalidArgumentException::fromEnumValue($argument->name, $value, $argument->suggestedValues);
}
private function resolveOption(Option $option, InputInterface $input): ?\BackedEnum
{
$value = $input->getOption($option->name);
if (null === $value) {
return null;
}
if ($value instanceof $option->typeName) {
return $value;
}
if (!\is_string($value) && !\is_int($value)) {
throw InvalidOptionException::fromEnumValue($option->name, get_debug_type($value), $option->suggestedValues);
}
return $option->typeName::tryFrom($value)
?? throw InvalidOptionException::fromEnumValue($option->name, $value, $option->suggestedValues);
}
}

View File

@@ -0,0 +1,75 @@
<?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\ArgumentResolver\ValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\InputInterface;
/**
* Resolves values from #[Argument] or #[Option] attributes for built-in PHP types.
*
* Handles: string, bool, int, float, array
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class BuiltinTypeValueResolver implements ValueResolverInterface
{
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
if ($member->isVariadic()) {
return [];
}
if ($argument = Argument::tryFrom($member->getMember())) {
if (is_subclass_of($argument->typeName, \BackedEnum::class)) {
return [];
}
return [$input->getArgument($argument->name)];
}
if ($option = Option::tryFrom($member->getMember())) {
if (is_subclass_of($option->typeName, \BackedEnum::class)) {
return [];
}
return [$this->resolveOption($option, $input)];
}
return [];
}
private function resolveOption(Option $option, InputInterface $input): mixed
{
$value = $input->getOption($option->name);
if (null === $value && \in_array($option->typeName, Option::ALLOWED_UNION_TYPES, true)) {
return true;
}
if ('array' === $option->typeName && $option->allowNull && [] === $value) {
return null;
}
if ('bool' === $option->typeName) {
if ($option->allowNull && null === $value) {
return null;
}
return $value ?? $option->default;
}
return $value;
}
}

View File

@@ -0,0 +1,102 @@
<?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\ArgumentResolver\ValueResolver;
use Psr\Clock\ClockInterface;
use Symfony\Component\Console\Attribute\MapDateTime;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\InputInterface;
/**
* Resolves a \DateTime* instance as a command input argument or option.
*
* @author Benjamin Eberlei <kontakt@beberlei.de>
* @author Tim Goudriaan <tim@codedmonkey.com>
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class DateTimeValueResolver implements ValueResolverInterface
{
public function __construct(
private readonly ?ClockInterface $clock = null,
) {
}
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
$type = $member->getType();
if (!$type instanceof \ReflectionNamedType || !is_a($type->getName(), \DateTimeInterface::class, true)) {
return [];
}
$attribute = $member->getAttribute(MapDateTime::class);
$inputName = $attribute?->argument ?? $attribute?->option ?? $member->getInputName();
// Try to get value from argument or option
$value = null;
if ($input->hasArgument($inputName)) {
$value = $input->getArgument($inputName);
} elseif ($input->hasOption($inputName)) {
$value = $input->getOption($inputName);
}
$class = \DateTimeInterface::class === $type->getName() ? \DateTimeImmutable::class : $type->getName();
if (!$value) {
if ($member->isNullable()) {
return [null];
}
if (!$this->clock) {
return [new $class()];
}
$value = $this->clock->now();
}
if ($value instanceof \DateTimeInterface) {
/** @var class-string<\DateTimeImmutable>|class-string<\DateTime> $class */
return [$value instanceof $class ? $value : $class::createFromInterface($value)];
}
$format = $attribute?->format;
/** @var class-string<\DateTimeImmutable>|class-string<\DateTime> $class */
if (null !== $format) {
$date = $class::createFromFormat($format, $value, $this->clock?->now()->getTimeZone());
if (($class::getLastErrors() ?: ['warning_count' => 0])['warning_count']) {
$date = false;
}
} else {
if (false !== filter_var($value, \FILTER_VALIDATE_INT, ['options' => ['min_range' => 0]])) {
$value = '@'.$value;
}
try {
$date = new $class($value, $this->clock?->now()->getTimeZone());
} catch (\Exception) {
$date = false;
}
}
if (!$date) {
$message = \sprintf('Invalid date given for parameter "$%s".', $argumentName);
if ($format) {
$message .= \sprintf(' Expected format: "%s".', $format);
}
$message .= ' Use #[MapDateTime(format: \'your-format\')] to specify a custom format.';
throw new \RuntimeException($message);
}
return [$date];
}
}

View File

@@ -0,0 +1,37 @@
<?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\ArgumentResolver\ValueResolver;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\InputInterface;
/**
* Yields the default value defined in the command signature when no input value has been explicitly passed.
*
* @author Iltar van der Berg <kjarli@gmail.com>
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class DefaultValueResolver implements ValueResolverInterface
{
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
if ($member->hasDefaultValue()) {
return [$member->getDefaultValue()];
}
if ($member->isNullable() && !$member->isVariadic()) {
return [null];
}
return [];
}
}

View File

@@ -0,0 +1,89 @@
<?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\ArgumentResolver\ValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\InputInterface;
/**
* Resolves the value of a input argument/option to an object holding the #[MapInput] attribute.
*
* @author Yonel Ceruto <open@yceruto.dev>
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class MapInputValueResolver implements ValueResolverInterface
{
public function __construct(
private readonly ValueResolverInterface $builtinTypeResolver,
private readonly ValueResolverInterface $backedEnumResolver,
private readonly ValueResolverInterface $dateTimeResolver,
) {
}
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
if (!$attribute = MapInput::tryFrom($member->getMember())) {
return [];
}
return [$this->resolveMapInput($attribute, $input)];
}
private function resolveMapInput(MapInput $mapInput, InputInterface $input): object
{
$instance = $mapInput->getClass()->newInstanceWithoutConstructor();
foreach ($mapInput->getDefinition() as $name => $spec) {
// ignore required arguments that are not set yet (may happen in interactive mode)
if ($spec instanceof Argument && $spec->isRequired() && \in_array($input->getArgument($spec->name), [null, []], true)) {
continue;
}
$instance->$name = match (true) {
$spec instanceof Argument => $this->resolveArgumentSpec($spec, $mapInput->getClass()->getProperty($name), $input),
$spec instanceof Option => $this->resolveOptionSpec($spec, $mapInput->getClass()->getProperty($name), $input),
$spec instanceof MapInput => $this->resolveMapInput($spec, $input),
};
}
return $instance;
}
private function resolveArgumentSpec(Argument $argument, \ReflectionProperty $property, InputInterface $input): mixed
{
if (is_subclass_of($argument->typeName, \BackedEnum::class)) {
return iterator_to_array($this->backedEnumResolver->resolve($property->name, $input, new ReflectionMember($property)))[0] ?? null;
}
if (is_a($argument->typeName, \DateTimeInterface::class, true)) {
return iterator_to_array($this->dateTimeResolver->resolve($property->name, $input, new ReflectionMember($property)))[0] ?? null;
}
return iterator_to_array($this->builtinTypeResolver->resolve($property->name, $input, new ReflectionMember($property)))[0] ?? null;
}
private function resolveOptionSpec(Option $option, \ReflectionProperty $property, InputInterface $input): mixed
{
if (is_subclass_of($option->typeName, \BackedEnum::class)) {
return iterator_to_array($this->backedEnumResolver->resolve($property->name, $input, new ReflectionMember($property)))[0] ?? null;
}
if (is_a($option->typeName, \DateTimeInterface::class, true)) {
return iterator_to_array($this->dateTimeResolver->resolve($property->name, $input, new ReflectionMember($property)))[0] ?? null;
}
return iterator_to_array($this->builtinTypeResolver->resolve($property->name, $input, new ReflectionMember($property)))[0] ?? null;
}
}

View File

@@ -0,0 +1,81 @@
<?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\ArgumentResolver\ValueResolver;
use Psr\Container\ContainerInterface;
use Symfony\Component\Console\ArgumentResolver\Exception\NearMissValueResolverException;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\DependencyInjection\Exception\RuntimeException;
/**
* Yields a service from a service locator keyed by command and argument name.
*
* @author Nicolas Grekas <p@tchwork.com>
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class ServiceValueResolver implements ValueResolverInterface
{
public function __construct(
private readonly ContainerInterface $container,
) {
}
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
$command = $input->getFirstArgument();
if ($command && $this->container->has($command)) {
$locator = $this->container->get($command);
if ($locator instanceof ContainerInterface && $locator->has($argumentName)) {
try {
return [$locator->get($argumentName)];
} catch (RuntimeException|\Throwable $e) {
$what = \sprintf('argument $%s', $argumentName);
$message = str_replace(\sprintf('service "%s"', $argumentName), $what, $e->getMessage());
$what .= \sprintf(' of command "%s"', $command);
$message = preg_replace('/service "\.service_locator\.[^"]++"/', $what, $message);
if ($e->getMessage() === $message) {
$message = \sprintf('Cannot resolve %s: %s', $what, $message);
}
throw new NearMissValueResolverException($message, $e->getCode(), $e);
}
}
}
$type = $member->getType();
if (!$type instanceof \ReflectionNamedType || $type->isBuiltin()) {
return [];
}
$typeName = $type->getName();
if (!$this->container->has($typeName)) {
return [];
}
try {
$service = $this->container->get($typeName);
if (!$service instanceof $typeName) {
throw new NearMissValueResolverException(\sprintf('Service "%s" exists in the container but is not an instance of "%s".', $typeName, $typeName));
}
return [$service];
} catch (\Throwable $e) {
throw new NearMissValueResolverException(\sprintf('Cannot resolve parameter "$%s" of type "%s": %s', $argumentName, $typeName, $e->getMessage()), previous: $e);
}
}
}

View File

@@ -0,0 +1,87 @@
<?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\ArgumentResolver\ValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Uid\AbstractUid;
/**
* Resolves an AbstractUid instance from a Command argument or option.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class UidValueResolver implements ValueResolverInterface
{
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
if ($argument = Argument::tryFrom($member->getMember())) {
if (!is_subclass_of($argument->typeName, AbstractUid::class)) {
return [];
}
return [$this->resolveArgument($argument, $input)];
}
if ($option = Option::tryFrom($member->getMember())) {
if (!is_subclass_of($option->typeName, AbstractUid::class)) {
return [];
}
return [$this->resolveOption($option, $input)];
}
return [];
}
private function resolveArgument(Argument $argument, InputInterface $input): ?AbstractUid
{
$value = $input->getArgument($argument->name);
if (null === $value) {
return null;
}
if ($value instanceof $argument->typeName) {
return $value;
}
if (!\is_string($value) || !$argument->typeName::isValid($value)) {
throw new InvalidArgumentException(\sprintf('The uid for the "%s" argument is invalid.', $argument->name));
}
return $argument->typeName::fromString($value);
}
private function resolveOption(Option $option, InputInterface $input): ?AbstractUid
{
$value = $input->getOption($option->name);
if (null === $value) {
return null;
}
if ($value instanceof $option->typeName) {
return $value;
}
if (!\is_string($value) || !$option->typeName::isValid($value)) {
throw new InvalidOptionException(\sprintf('The uid for the "--%s" option is invalid.', $option->name));
}
return $option->typeName::fromString($value);
}
}

View File

@@ -0,0 +1,30 @@
<?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\ArgumentResolver\ValueResolver;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\InputInterface;
/**
* Responsible for resolving the value of a Command argument based on its
* parameter metadata and the Command MapInput.
*
* @author Nicolas Grekas <p@tchwork.com>
* @author Robin Chalas <robin.chalas@gmail.com>
*/
interface ValueResolverInterface
{
/**
* Returns the possible value(s) for the argument.
*/
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable;
}

View File

@@ -0,0 +1,54 @@
<?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\ArgumentResolver\ValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\InputInterface;
/**
* Yields a variadic argument's values from the input.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class VariadicValueResolver implements ValueResolverInterface
{
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
if (!$member->isVariadic()) {
return [];
}
if ($argument = Argument::tryFrom($member->getMember())) {
$values = $input->getArgument($argument->name);
if (!\is_array($values)) {
throw new \InvalidArgumentException(\sprintf('The action argument "...$%1$s" is required to be an array, the input argument "%1$s" contains a type of "%2$s" instead.', $argument->name, get_debug_type($values)));
}
return $values;
}
if ($option = Option::tryFrom($member->getMember())) {
$values = $input->getOption($option->name);
if (!\is_array($values)) {
throw new \InvalidArgumentException(\sprintf('The action argument "...$%1$s" is required to be an array, the input option "--%1$s" contains a type of "%2$s" instead.', $option->name, get_debug_type($values)));
}
return $values;
}
return [];
}
}

View File

@@ -14,24 +14,23 @@ 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::TARGET_PROPERTY)]
class Argument
{
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
public string|bool|int|float|array|null $default = null;
public array|\Closure $suggestedValues;
private string|bool|int|float|array|null $default = null;
private array|\Closure $suggestedValues;
private ?int $mode = null;
/**
* @internal
*
* @var string|class-string<\BackedEnum>
*/
private string $typeName = '';
public string $typeName = '';
private ?int $mode = null;
private ?InteractiveAttributeInterface $interactiveAttribute = null;
/**
@@ -68,11 +67,6 @@ class Argument
}
$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 %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();
@@ -80,9 +74,9 @@ class Argument
$self->default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null;
$isOptional = $reflection->hasDefaultValue() || $reflection->isNullable();
$isOptional = $reflection->hasDefaultValue() || $reflection->isNullable() || $reflection->isVariadic();
$self->mode = $isOptional ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
if ('array' === $self->typeName) {
if ('array' === $self->typeName || $reflection->isVariadic()) {
$self->mode |= InputArgument::IS_ARRAY;
}
@@ -92,7 +86,7 @@ class Argument
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
}
if ($isBackedEnum && !$self->suggestedValues) {
if (is_subclass_of($self->typeName, \BackedEnum::class) && !$self->suggestedValues) {
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
}
@@ -115,20 +109,6 @@ class Argument
return new InputArgument($this->name, $this->mode, $this->description, $this->default, $suggestedValues);
}
/**
* @internal
*/
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 $value;
}
/**
* @internal
*/

View File

@@ -0,0 +1,26 @@
<?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;
/**
* Service tag to autoconfigure targeted value resolvers.
*/
#[\Attribute(\Attribute::TARGET_CLASS)]
class AsTargetedValueResolver
{
/**
* @param string|null $name The name with which the resolver can be targeted
*/
public function __construct(public readonly ?string $name = null)
{
}
}

36
Attribute/MapDateTime.php Normal file
View File

@@ -0,0 +1,36 @@
<?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;
/**
* Defines how a DateTime parameter should be resolved from command input.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
#[\Attribute(\Attribute::TARGET_PARAMETER)]
class MapDateTime
{
/**
* @param string|null $format The DateTime format (@see https://php.net/datetime.format)
* @param string|null $argument The argument name to read from (defaults to parameter name)
* @param string|null $option The option name to read from (mutually exclusive with $argument)
*/
public function __construct(
public readonly ?string $format = null,
public readonly ?string $argument = null,
public readonly ?string $option = null,
) {
if ($argument && $option) {
throw new \LogicException('MapDateTime cannot specify both argument and option.');
}
}
}

View File

@@ -84,25 +84,6 @@ final class MapInput
return $self;
}
/**
* @internal
*/
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 && $spec->isRequired() && \in_array($input->getArgument($spec->name), [null, []], true)) {
continue;
}
$instance->$name = $spec->resolveValue($input);
}
return $instance;
}
/**
* @internal
*/
@@ -152,6 +133,79 @@ final class MapInput
}
}
/**
* @internal
*
* @return \ReflectionClass<object>
*/
public function getClass(): \ReflectionClass
{
return $this->class;
}
/**
* @internal
*
* @return array<string, Argument|Option|self>
*/
public function getDefinition(): array
{
return $this->definition;
}
/**
* Creates a populated instance of the DTO from command input.
*
* @internal
*/
public function createInstance(InputInterface $input): object
{
$instance = $this->class->newInstanceWithoutConstructor();
foreach ($this->definition as $name => $spec) {
if ($spec instanceof Argument) {
$value = $input->getArgument($spec->name);
if ($spec->isRequired() && \in_array($value, [null, []], true)) {
continue;
}
$instance->$name = $this->resolveValue($spec->typeName, $value, $spec->default);
} elseif ($spec instanceof Option) {
$value = $input->getOption($spec->name);
$instance->$name = $this->resolveValue($spec->typeName, $value, $spec->default);
} elseif ($spec instanceof self) {
$instance->$name = $spec->createInstance($input);
}
}
return $instance;
}
private function resolveValue(string $typeName, mixed $value, mixed $default): mixed
{
if (null === $value) {
return $default;
}
if ('' === $value) {
return $value;
}
if (is_subclass_of($typeName, \BackedEnum::class)) {
return $value instanceof $typeName ? $value : $typeName::tryFrom($value);
}
if (is_a($typeName, \DateTimeInterface::class, true)) {
if ($value instanceof \DateTimeInterface) {
return $value;
}
$class = \DateTimeInterface::class === $typeName ? \DateTimeImmutable::class : $typeName;
return new $class($value);
}
return $value;
}
/**
* @internal
*

View File

@@ -14,26 +14,26 @@ 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;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\String\UnicodeString;
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::TARGET_PROPERTY)]
class Option
{
private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array'];
private const ALLOWED_UNION_TYPES = ['bool|string', 'bool|int', 'bool|float'];
public const ALLOWED_UNION_TYPES = ['bool|string', 'bool|int', 'bool|float'];
public string|bool|int|float|array|null $default = null;
public array|\Closure $suggestedValues;
private string|bool|int|float|array|null $default = null;
private array|\Closure $suggestedValues;
private ?int $mode = null;
/**
* @internal
*
* @var string|class-string<\BackedEnum>
*/
private string $typeName = '';
private bool $allowNull = false;
public string $typeName = '';
/** @internal */
public bool $allowNull = false;
private ?int $mode = null;
private string $memberName = '';
private string $sourceName = '';
@@ -71,7 +71,8 @@ class Option
$name = $reflection->getName();
$type = $reflection->getType();
if (!$reflection->hasDefaultValue()) {
// Variadic parameters implicitly default to an empty array
if (!$reflection->isVariadic() && !$reflection->hasDefaultValue()) {
throw new LogicException(\sprintf('The option %s "$%s" of "%s" must declare a default value.', $self->memberName, $name, $self->sourceName));
}
@@ -79,7 +80,7 @@ class Option
$self->name = (new UnicodeString($name))->kebab();
}
$self->default = $reflection->getDefaultValue();
$self->default = $reflection->isVariadic() ? [] : $reflection->getDefaultValue();
$self->allowNull = $reflection->isNullable();
if ($type instanceof \ReflectionUnionType) {
@@ -91,11 +92,6 @@ class Option
}
$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 %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 %s "$%s" of "%s" must not be nullable when it has a default boolean value.', $self->memberName, $name, $self->sourceName));
@@ -110,7 +106,7 @@ class Option
if (false !== $self->default) {
$self->mode |= InputOption::VALUE_NEGATABLE;
}
} elseif ('array' === $self->typeName) {
} elseif ('array' === $self->typeName || $reflection->isVariadic()) {
$self->mode = InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY;
} else {
$self->mode = InputOption::VALUE_REQUIRED;
@@ -120,7 +116,7 @@ class Option
$self->suggestedValues = [$instance, $self->suggestedValues[1]];
}
if ($isBackedEnum && !$self->suggestedValues) {
if (is_subclass_of($self->typeName, \BackedEnum::class) && !$self->suggestedValues) {
$self->suggestedValues = array_column($self->typeName::cases(), 'value');
}
@@ -138,36 +134,6 @@ class Option
return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $default, $suggestedValues);
}
/**
* @internal
*/
public function resolveValue(InputInterface $input): mixed
{
$value = $input->getOption($this->name);
if (null === $value && \in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) {
return true;
}
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);
}
if ('array' === $this->typeName && $this->allowNull && [] === $value) {
return null;
}
if ('bool' !== $this->typeName) {
return $value;
}
if ($this->allowNull && null === $value) {
return null;
}
return $value ?? $this->default;
}
private function handleUnion(\ReflectionUnionType $type): self
{
$types = array_map(

View File

@@ -11,6 +11,8 @@
namespace Symfony\Component\Console\Attribute\Reflection;
use Symfony\Component\String\UnicodeString;
/**
* @internal
*/
@@ -33,6 +35,21 @@ class ReflectionMember
return ($this->member->getAttributes($class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance();
}
/**
* @template T of object
*
* @param class-string<T> $class
*
* @return list<T>
*/
public function getAttributes(string $class): array
{
return array_map(
static fn (\ReflectionAttribute $attribute) => $attribute->newInstance(),
$this->member->getAttributes($class, \ReflectionAttribute::IS_INSTANCEOF)
);
}
public function getSourceName(): string
{
if ($this->member instanceof \ReflectionProperty) {
@@ -102,8 +119,23 @@ class ReflectionMember
return $this->member instanceof \ReflectionParameter;
}
public function isVariadic(): bool
{
return $this->member instanceof \ReflectionParameter && $this->member->isVariadic();
}
public function isProperty(): bool
{
return $this->member instanceof \ReflectionProperty;
}
public function getMember(): \ReflectionParameter|\ReflectionProperty
{
return $this->member;
}
public function getInputName(): string
{
return (new UnicodeString($this->member->getName()))->kebab()->toString();
}
}

View File

@@ -0,0 +1,31 @@
<?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\ArgumentResolver\ValueResolver\ValueResolverInterface;
/**
* Defines which value resolver should be used for a given parameter.
*/
#[\Attribute(\Attribute::TARGET_PARAMETER | \Attribute::IS_REPEATABLE)]
class ValueResolver
{
/**
* @param class-string<ValueResolverInterface>|string $resolver The class name of the resolver to use
* @param bool $disabled Whether this value resolver is disabled; this allows to enable a value resolver globally while disabling it in specific cases
*/
public function __construct(
public string $resolver,
public bool $disabled = false,
) {
}
}

View File

@@ -5,6 +5,8 @@ CHANGELOG
---
* Add support for method-based commands with `#[AsCommand]` attribute
* Add argument resolver support
* Add `BackedEnum` and `DateTimeInterface` support to `#[MapInput]`
8.0
---

View File

@@ -12,13 +12,13 @@
namespace Symfony\Component\Console\Command;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\ArgumentResolver\ArgumentResolver;
use Symfony\Component\Console\ArgumentResolver\ArgumentResolverInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Interact;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Cursor;
use Symfony\Component\Console\Exception\LogicException;
use Symfony\Component\Console\Exception\RuntimeException;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputInterface;
@@ -46,6 +46,7 @@ class InvokableCommand implements SignalableCommandInterface
public function __construct(
private readonly Command $command,
callable $code,
private ?ArgumentResolverInterface $argumentResolver = null,
) {
$this->code = $code;
$this->signalableCommand = $code instanceof SignalableCommandInterface ? $code : null;
@@ -131,44 +132,61 @@ class InvokableCommand implements SignalableCommandInterface
private function getParameters(\ReflectionFunction $function, InputInterface $input, OutputInterface $output): array
{
$parameters = [];
foreach ($function->getParameters() as $parameter) {
if ($argument = Argument::tryFrom($parameter)) {
$parameters[] = $argument->resolveValue($input);
$coreUtilities = [];
$needsArgumentResolver = false;
continue;
foreach ($function->getParameters() as $index => $param) {
$type = $param->getType();
if ($type instanceof \ReflectionNamedType) {
$argument = match ($type->getName()) {
InputInterface::class => $input,
OutputInterface::class => $output,
SymfonyStyle::class => new SymfonyStyle($input, $output),
Cursor::class => new Cursor($output),
Application::class => $this->command->getApplication(),
Command::class, self::class => $this->command,
default => null,
};
if (null !== $argument) {
$coreUtilities[$index] = $argument;
continue;
}
}
if ($option = Option::tryFrom($parameter)) {
$parameters[] = $option->resolveValue($input);
continue;
}
if ($in = MapInput::tryFrom($parameter)) {
$parameters[] = $in->resolveValue($input);
continue;
}
$type = $parameter->getType();
if (!$type instanceof \ReflectionNamedType) {
throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported.', $parameter->getName()));
}
$parameters[] = match ($type->getName()) {
InputInterface::class => $input,
OutputInterface::class => $output,
Cursor::class => new Cursor($output),
SymfonyStyle::class => new SymfonyStyle($input, $output),
Command::class => $this->command,
Application::class => $this->command->getApplication(),
default => throw new RuntimeException(\sprintf('Unsupported type "%s" for parameter "$%s".', $type->getName(), $parameter->getName())),
};
$needsArgumentResolver = true;
}
return $parameters ?: [$input, $output];
if (!$needsArgumentResolver) {
return $coreUtilities;
}
if (null === $this->argumentResolver) {
$this->argumentResolver = $this->command->getApplication()?->getArgumentResolver() ?? new ArgumentResolver(
ArgumentResolver::getDefaultArgumentValueResolvers()
);
}
$closure = $function->getClosure();
$resolvedArgs = $this->argumentResolver->getArguments($input, $closure, $function);
$parameters = [];
$resolvedIndex = 0;
foreach ($function->getParameters() as $index => $param) {
if (isset($coreUtilities[$index])) {
$parameters[] = $coreUtilities[$index];
} elseif ($param->isVariadic()) {
// Variadic parameters consume all remaining resolved arguments
$parameters = [...$parameters, ...\array_slice($resolvedArgs, $resolvedIndex)];
break;
} else {
$parameters[] = $resolvedArgs[$resolvedIndex++] ?? null;
}
}
return $parameters;
}
public function getSubscribedSignals(): array

View File

@@ -0,0 +1,189 @@
<?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\DependencyInjection;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\DependencyInjection\Attribute\AutowireCallable;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Component\DependencyInjection\ChildDefinition;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\Compiler\ServiceLocatorTagPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\DependencyInjection\Exception\InvalidArgumentException;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\TypedReference;
use Symfony\Component\VarExporter\ProxyHelper;
/**
* Creates the service-locators required by ServiceValueResolver for commands.
*
* @author Nicolas Grekas <p@tchwork.com>
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class RegisterCommandArgumentLocatorsPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('console.argument_resolver.service')) {
return;
}
$parameterBag = $container->getParameterBag();
$serviceLocators = [];
foreach ($container->findTaggedServiceIds('console.command.service_arguments', true) as $id => $tags) {
$def = $container->getDefinition($id);
$class = $def->getClass();
$autowire = $def->isAutowired();
$bindings = $def->getBindings();
// Resolve service class, taking parent definitions into account
while ($def instanceof ChildDefinition) {
$def = $container->findDefinition($def->getParent());
$class = $class ?: $def->getClass();
$bindings += $def->getBindings();
}
$class = $parameterBag->resolveValue($class);
if (!$r = $container->getReflectionClass($class)) {
throw new InvalidArgumentException(\sprintf('Class "%s" used for command "%s" cannot be found.', $class, $id));
}
// Get all console.command tags to find command names and their methods
$commandTags = $container->getDefinition($id)->getTag('console.command');
$manualArguments = [];
// Validate and collect explicit per-arguments service references
foreach ($tags as $attributes) {
if (!isset($attributes['argument']) && !isset($attributes['id'])) {
$autowire = true;
continue;
}
foreach (['argument', 'id'] as $k) {
if (!isset($attributes[$k][0])) {
throw new InvalidArgumentException(\sprintf('Missing "%s" attribute on tag "console.command.service_arguments" %s for service "%s".', $k, json_encode($attributes, \JSON_UNESCAPED_UNICODE), $id));
}
}
$manualArguments[$attributes['argument']] = $attributes['id'];
}
foreach ($commandTags as $commandTag) {
$commandName = $commandTag['command'] ?? null;
if (!$commandName) {
continue;
}
$methodName = $commandTag['method'] ?? '__invoke';
if (!$r->hasMethod($methodName)) {
continue;
}
$method = $r->getMethod($methodName);
$arguments = [];
$erroredIds = 0;
foreach ($method->getParameters() as $p) {
$type = preg_replace('/(^|[(|&])\\\\/', '\1', $target = ltrim(ProxyHelper::exportType($p) ?? '', '?'));
$invalidBehavior = ContainerInterface::IGNORE_ON_INVALID_REFERENCE;
$autowireAttributes = null;
$parsedName = $p->name;
$k = null;
if (isset($manualArguments[$p->name])) {
$target = $manualArguments[$p->name];
if ('?' !== $target[0]) {
$invalidBehavior = ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE;
} elseif ('' === $target = substr($target, 1)) {
throw new InvalidArgumentException(\sprintf('A "console.command.service_arguments" tag must have non-empty "id" attributes for service "%s".', $id));
} elseif ($p->allowsNull() && !$p->isOptional()) {
$invalidBehavior = ContainerInterface::NULL_ON_INVALID_REFERENCE;
}
} elseif (isset($bindings[$bindingName = $type.' $'.$name = Target::parseName($p, $k, $parsedName)])
|| isset($bindings[$bindingName = $type.' $'.$parsedName])
|| isset($bindings[$bindingName = '$'.$name])
|| isset($bindings[$bindingName = $type])
) {
$binding = $bindings[$bindingName];
[$bindingValue, $bindingId, , $bindingType, $bindingFile] = $binding->getValues();
$binding->setValues([$bindingValue, $bindingId, true, $bindingType, $bindingFile]);
$arguments[$p->name] = $bindingValue;
continue;
} elseif (!$autowire || (!($autowireAttributes = $p->getAttributes(Autowire::class, \ReflectionAttribute::IS_INSTANCEOF)) && (!$type || '\\' !== $target[0]))) {
continue;
} elseif (!$autowireAttributes && is_subclass_of($type, \UnitEnum::class)) {
// Do not attempt to register enum typed arguments if not already present in bindings
continue;
} elseif (!$p->allowsNull()) {
$invalidBehavior = ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE;
}
// Skip console-specific types that are resolved by other resolvers
if (InputInterface::class === $type || OutputInterface::class === $type) {
continue;
}
if ($autowireAttributes) {
$attribute = $autowireAttributes[0]->newInstance();
$value = $parameterBag->resolveValue($attribute->value);
if ($attribute instanceof AutowireCallable) {
$arguments[$p->name] = $attribute->buildDefinition($value, $type, $p);
} elseif ($value instanceof Reference) {
$arguments[$p->name] = $type ? new TypedReference($value, $type, $invalidBehavior, $p->name) : new Reference($value, $invalidBehavior);
} else {
$arguments[$p->name] = new Reference('.value.'.$container->hash($value));
$container->register((string) $arguments[$p->name], 'mixed')
->setFactory('current')
->addArgument([$value]);
}
continue;
}
if ($type && !$p->isOptional() && !$p->allowsNull() && !class_exists($type) && !interface_exists($type, false)) {
$message = \sprintf('Cannot determine command argument for "%s::%s()": the $%s argument is type-hinted with the non-existent class or interface: "%s".', $class, $method->name, $p->name, $type);
// See if the type-hint lives in the same namespace as the command
if (0 === strncmp($type, $class, strrpos($class, '\\'))) {
$message .= ' Did you forget to add a use statement?';
}
$container->register($erroredId = '.errored.'.$container->hash($message), $type)
->addError($message);
$arguments[$p->name] = new Reference($erroredId, ContainerInterface::RUNTIME_EXCEPTION_ON_INVALID_REFERENCE);
++$erroredIds;
} else {
$target = preg_replace('/(^|[(|&])\\\\/', '\1', $target);
$arguments[$p->name] = $type ? new TypedReference($target, $type, $invalidBehavior, Target::parseName($p)) : new Reference($target, $invalidBehavior);
}
}
if ($arguments) {
$serviceLocators[$commandName] = ServiceLocatorTagPass::register($container, $arguments, \count($arguments) !== $erroredIds ? $commandName : null);
}
}
}
$container->getDefinition('console.argument_resolver.service')
->replaceArgument(0, ServiceLocatorTagPass::register($container, $serviceLocators));
}
}

View File

@@ -0,0 +1,61 @@
<?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\DependencyInjection;
use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
/**
* Removes empty service-locators registered for ServiceValueResolver for commands.
*
* @author Robin Chalas <robin.chalas@gmail.com>
*/
final class RemoveEmptyCommandArgumentLocatorsPass implements CompilerPassInterface
{
public function process(ContainerBuilder $container): void
{
if (!$container->hasDefinition('console.argument_resolver.service')) {
return;
}
$serviceResolverDef = $container->getDefinition('console.argument_resolver.service');
$commandLocatorRef = $serviceResolverDef->getArgument(0);
if (!$commandLocatorRef) {
return;
}
$commandLocator = $container->getDefinition((string) $commandLocatorRef);
if ($commandLocator->getFactory()) {
$commandLocator = $container->getDefinition($commandLocator->getFactory()[0]);
}
$commands = $commandLocator->getArgument(0);
foreach ($commands as $commandName => $argumentRef) {
$argumentLocator = $container->getDefinition((string) $argumentRef->getValues()[0]);
if ($argumentLocator->getFactory()) {
$argumentLocator = $container->getDefinition($argumentLocator->getFactory()[0]);
}
if (!$argumentLocator->getArgument(0)) {
$reason = \sprintf('Removing service-argument resolver for command "%s": no corresponding services exist for the referenced types.', $commandName);
unset($commands[$commandName]);
$container->log($this, $reason);
}
}
$commandLocator->replaceArgument(0, $commands);
}
}

View File

@@ -33,7 +33,7 @@ final class Interaction
public function interact(InputInterface $input, OutputInterface $output, \Closure $parameterResolver): void
{
if ($this->owner instanceof MapInput) {
$function = $this->attribute->getFunction($this->owner->resolveValue($input));
$function = $this->attribute->getFunction($this->owner->createInstance($input));
$function->invoke(...$parameterResolver($function, $input, $output));
$this->owner->setValue($input, $function->getClosureThis());

View File

@@ -0,0 +1,227 @@
<?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\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\BackedEnumValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
class BackedEnumValueResolverTest extends TestCase
{
public function testResolveBackedEnumArgument()
{
$resolver = new BackedEnumValueResolver();
$input = new ArrayInput(['status' => 'pending'], new InputDefinition([
new InputArgument('status'),
]));
$command = new class {
public function __invoke(
#[Argument]
BackedEnumTestStatus $status,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('status', $input, $member));
$this->assertSame([BackedEnumTestStatus::Pending], $result);
}
public function testResolveBackedEnumOption()
{
$resolver = new BackedEnumValueResolver();
$input = new ArrayInput(['--status' => 'completed'], new InputDefinition([
new InputOption('status'),
]));
$command = new class {
public function __invoke(
#[Option]
BackedEnumTestStatus $status = BackedEnumTestStatus::Pending,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('status', $input, $member));
$this->assertSame([BackedEnumTestStatus::Completed], $result);
}
public function testBackedEnumArgumentThrowsOnInvalidValue()
{
$this->expectException(InvalidArgumentException::class);
$resolver = new BackedEnumValueResolver();
$input = new ArrayInput(['status' => 'invalid'], new InputDefinition([
new InputArgument('status'),
]));
$command = new class {
public function __invoke(
#[Argument]
BackedEnumTestStatus $status,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
iterator_to_array($resolver->resolve('status', $input, $member));
}
public function testBackedEnumOptionThrowsOnInvalidValue()
{
$this->expectException(InvalidOptionException::class);
$resolver = new BackedEnumValueResolver();
$input = new ArrayInput(['--status' => 'invalid'], new InputDefinition([
new InputOption('status'),
]));
$command = new class {
public function __invoke(
#[Option]
BackedEnumTestStatus $status = BackedEnumTestStatus::Pending,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
iterator_to_array($resolver->resolve('status', $input, $member));
}
public function testDoesNotResolveNonEnumArgument()
{
$resolver = new BackedEnumValueResolver();
$input = new ArrayInput(['username' => 'john'], new InputDefinition([
new InputArgument('username'),
]));
$command = new class {
public function __invoke(
#[Argument]
string $username,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('username', $input, $member));
$this->assertSame([], $result);
}
public function testDoesNotResolveNonEnumOption()
{
$resolver = new BackedEnumValueResolver();
$input = new ArrayInput(['--name' => 'john'], new InputDefinition([
new InputOption('name'),
]));
$command = new class {
public function __invoke(
#[Option]
string $name = '',
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('name', $input, $member));
$this->assertSame([], $result);
}
public function testDoesNotResolveWithoutAttribute()
{
$resolver = new BackedEnumValueResolver();
$input = new ArrayInput(['status' => 'pending'], new InputDefinition([
new InputArgument('status'),
]));
$function = static fn (BackedEnumTestStatus $status) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('status', $input, $member));
$this->assertSame([], $result);
}
public function testResolveIntBackedEnumArgument()
{
$resolver = new BackedEnumValueResolver();
$input = new ArrayInput(['priority' => 1], new InputDefinition([
new InputArgument('priority'),
]));
$command = new class {
public function __invoke(
#[Argument]
BackedEnumTestPriority $priority,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('priority', $input, $member));
$this->assertSame([BackedEnumTestPriority::High], $result);
}
}
enum BackedEnumTestStatus: string
{
case Pending = 'pending';
case Completed = 'completed';
case Failed = 'failed';
}
enum BackedEnumTestPriority: int
{
case Low = 0;
case High = 1;
case Critical = 2;
}

View File

@@ -0,0 +1,268 @@
<?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\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\BuiltinTypeValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
class BuiltinTypeValueResolverTest extends TestCase
{
public function testResolveStringArgument()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput(['username' => 'john'], new InputDefinition([
new InputArgument('username'),
]));
$command = new class {
public function __invoke(
#[Argument]
string $username,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('username', $input, $member);
$this->assertSame(['john'], $result);
}
public function testResolveStringOption()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput(['--name' => 'john'], new InputDefinition([
new InputOption('name'),
]));
$command = new class {
public function __invoke(
#[Option]
string $name = '',
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('name', $input, $member);
$this->assertSame(['john'], $result);
}
public function testDelegatesToBackedEnumValueResolverForEnumArgument()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput(['status' => 'pending'], new InputDefinition([
new InputArgument('status'),
]));
$command = new class {
public function __invoke(
#[Argument]
DummyBackedEnum $status,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('status', $input, $member);
// BuiltinTypeValueResolver returns empty for enums - BackedEnumValueResolver handles them
$this->assertSame([], $result);
}
public function testDelegatesToBackedEnumValueResolverForEnumOption()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput(['--status' => 'completed'], new InputDefinition([
new InputOption('status'),
]));
$command = new class {
public function __invoke(
#[Option]
DummyBackedEnum $status = DummyBackedEnum::Pending,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('status', $input, $member);
// BuiltinTypeValueResolver returns empty for enums - BackedEnumValueResolver handles them
$this->assertSame([], $result);
}
public function testResolveBoolOption()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput(['--force' => true], new InputDefinition([
new InputOption('force'),
]));
$command = new class {
public function __invoke(
#[Option]
bool $force = false,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('force', $input, $member);
$this->assertSame([true], $result);
}
public function testResolveNullableBoolOptionWithNullValue()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput([], new InputDefinition([
new InputOption('force'),
]));
$command = new class {
public function __invoke(
#[Option]
?bool $force = null,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('force', $input, $member);
$this->assertSame([false], $result);
}
public function testResolveArrayOption()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput(['--tags' => ['foo', 'bar']], new InputDefinition([
new InputOption('tags', mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
]));
$command = new class {
public function __invoke(
#[Option]
array $tags = [],
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('tags', $input, $member);
$this->assertSame([['foo', 'bar']], $result);
}
public function testResolveNullableArrayOptionWithEmptyValue()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput([], new InputDefinition([
new InputOption('tags', mode: InputOption::VALUE_IS_ARRAY | InputOption::VALUE_OPTIONAL),
]));
$command = new class {
public function __invoke(
#[Option]
?array $tags = null,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('tags', $input, $member);
$this->assertSame([null], $result);
}
public function testDoesNotResolveWithoutAttribute()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput(['username' => 'john'], new InputDefinition([
new InputArgument('username'),
]));
$function = static fn (string $username) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('username', $input, $member);
$this->assertSame([], $result);
}
public function testResolveIntegerArgument()
{
$resolver = new BuiltinTypeValueResolver();
$input = new ArrayInput(['count' => 42], new InputDefinition([
new InputArgument('count'),
]));
$command = new class {
public function __invoke(
#[Argument]
int $count,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('count', $input, $member);
$this->assertSame([42], $result);
}
}
enum DummyBackedEnum: string
{
case Pending = 'pending';
case Completed = 'completed';
case Failed = 'failed';
}

View File

@@ -0,0 +1,366 @@
<?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\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Clock\MockClock;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\DateTimeValueResolver;
use Symfony\Component\Console\Attribute\MapDateTime;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
class DateTimeValueResolverTest extends TestCase
{
private readonly string $defaultTimezone;
protected function setUp(): void
{
$this->defaultTimezone = date_default_timezone_get();
}
protected function tearDown(): void
{
date_default_timezone_set($this->defaultTimezone);
}
public static function getTimeZones()
{
yield ['UTC', false];
yield ['Pacific/Honolulu', false];
yield ['America/Toronto', false];
yield ['UTC', true];
yield ['Pacific/Honolulu', true];
yield ['America/Toronto', true];
}
public static function getClasses()
{
yield [\DateTimeInterface::class];
yield [\DateTime::class];
yield [\DateTimeImmutable::class];
yield [FooDateTime::class];
}
public function testUnsupportedArgument()
{
$resolver = new DateTimeValueResolver();
$input = new ArrayInput(['created-at' => 'now'], new InputDefinition([
new InputArgument('created-at'),
]));
$function = static fn (string $createdAt) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$this->assertSame([], $resolver->resolve('createdAt', $input, $member));
}
#[DataProvider('getTimeZones')]
public function testFullDate(string $timezone, bool $withClock)
{
date_default_timezone_set($withClock ? 'UTC' : $timezone);
$resolver = new DateTimeValueResolver($withClock ? new MockClock('now', $timezone) : null);
$input = new ArrayInput(['created-at' => '2012-07-21 00:00:00'], new InputDefinition([
new InputArgument('created-at'),
]));
$function = static fn (\DateTimeImmutable $createdAt) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf(\DateTimeImmutable::class, $results[0]);
$this->assertSame($timezone, $results[0]->getTimezone()->getName(), 'Default timezone');
$this->assertEquals('2012-07-21 00:00:00', $results[0]->format('Y-m-d H:i:s'));
}
#[DataProvider('getTimeZones')]
public function testUnixTimestamp(string $timezone, bool $withClock)
{
date_default_timezone_set($withClock ? 'UTC' : $timezone);
$resolver = new DateTimeValueResolver($withClock ? new MockClock('now', $timezone) : null);
$input = new ArrayInput(['created-at' => '989541720'], new InputDefinition([
new InputArgument('created-at'),
]));
$function = static fn (\DateTimeImmutable $createdAt) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf(\DateTimeImmutable::class, $results[0]);
$this->assertSame('+00:00', $results[0]->getTimezone()->getName(), 'Timestamps are UTC');
$this->assertEquals('2001-05-11 00:42:00', $results[0]->format('Y-m-d H:i:s'));
}
public function testNullableWithEmptyArgument()
{
$resolver = new DateTimeValueResolver();
$input = new ArrayInput(['created-at' => ''], new InputDefinition([
new InputArgument('created-at'),
]));
$function = static fn (?\DateTimeImmutable $createdAt) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertNull($results[0]);
}
/**
* @param class-string<\DateTimeInterface> $class
*/
#[DataProvider('getClasses')]
public function testNow(string $class)
{
date_default_timezone_set($timezone = 'Pacific/Honolulu');
$resolver = new DateTimeValueResolver();
$input = new ArrayInput([], new InputDefinition([
new InputArgument('created-at', InputArgument::OPTIONAL),
]));
$command = new class {
public function __invoke(\DateTimeInterface $createdAt)
{
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
// Replace type with the class we're testing
$function = eval(\sprintf('return fn (%s $createdAt) => null;', $class));
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf($class, $results[0]);
$this->assertSame($timezone, $results[0]->getTimezone()->getName(), 'Default timezone');
$this->assertEquals('0', $results[0]->diff(new \DateTimeImmutable())->format('%s'));
}
/**
* @param class-string<\DateTimeInterface> $class
*/
#[DataProvider('getClasses')]
public function testNowWithClock(string $class)
{
date_default_timezone_set('Pacific/Honolulu');
$clock = new MockClock('2022-02-20 22:20:02');
$resolver = new DateTimeValueResolver($clock);
$input = new ArrayInput([], new InputDefinition([
new InputArgument('created-at', InputArgument::OPTIONAL),
]));
$function = eval(\sprintf('return fn (%s $createdAt) => null;', $class));
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf($class, $results[0]);
$this->assertSame('UTC', $results[0]->getTimezone()->getName(), 'Default timezone');
$this->assertEquals($clock->now(), $results[0]);
}
/**
* @param class-string<\DateTimeInterface> $class
*/
#[DataProvider('getClasses')]
public function testPreviouslyConvertedArgument(string $class)
{
$resolver = new DateTimeValueResolver();
$datetime = new \DateTimeImmutable();
$input = new ArrayInput(['created-at' => $datetime], new InputDefinition([
new InputArgument('created-at'),
]));
$function = eval(\sprintf('return fn (%s $createdAt) => null;', $class));
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertEquals($datetime, $results[0], 'The value is the same, but the class can be modified.');
$this->assertInstanceOf($class, $results[0]);
}
public function testCustomClass()
{
date_default_timezone_set('UTC');
$resolver = new DateTimeValueResolver();
$input = new ArrayInput(['created-at' => '2016-09-08 00:00:00'], new InputDefinition([
new InputArgument('created-at'),
]));
$function = static fn (FooDateTime $createdAt) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf(FooDateTime::class, $results[0]);
$this->assertEquals('2016-09-08 00:00:00+00:00', $results[0]->format('Y-m-d H:i:sP'));
}
#[DataProvider('getTimeZones')]
public function testDateTimeImmutable(string $timezone, bool $withClock)
{
date_default_timezone_set($withClock ? 'UTC' : $timezone);
$resolver = new DateTimeValueResolver($withClock ? new MockClock('now', $timezone) : null);
$input = new ArrayInput(['created-at' => '2016-09-08 00:00:00 +05:00'], new InputDefinition([
new InputArgument('created-at'),
]));
$function = static fn (\DateTimeImmutable $createdAt) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf(\DateTimeImmutable::class, $results[0]);
$this->assertSame('+05:00', $results[0]->getTimezone()->getName(), 'Input timezone');
$this->assertEquals('2016-09-08 00:00:00', $results[0]->format('Y-m-d H:i:s'));
}
#[DataProvider('getTimeZones')]
public function testWithFormat(string $timezone, bool $withClock)
{
date_default_timezone_set($withClock ? 'UTC' : $timezone);
$resolver = new DateTimeValueResolver($withClock ? new MockClock('now', $timezone) : null);
$input = new ArrayInput(['created-at' => '09-08-16 12:34:56'], new InputDefinition([
new InputArgument('created-at'),
]));
$command = new class {
public function __invoke(
#[MapDateTime(format: 'm-d-y H:i:s')]
\DateTimeInterface $createdAt,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf(\DateTimeImmutable::class, $results[0]);
$this->assertSame($timezone, $results[0]->getTimezone()->getName(), 'Default timezone');
$this->assertEquals('2016-09-08 12:34:56', $results[0]->format('Y-m-d H:i:s'));
}
public function testWithOption()
{
date_default_timezone_set('UTC');
$resolver = new DateTimeValueResolver();
$input = new ArrayInput(['--created-at' => '2016-09-08 00:00:00'], new InputDefinition([
new InputOption('created-at'),
]));
$command = new class {
public function __invoke(
#[MapDateTime(option: 'created-at')]
\DateTimeImmutable $createdAt,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$results = $resolver->resolve('createdAt', $input, $member);
$this->assertCount(1, $results);
$this->assertInstanceOf(\DateTimeImmutable::class, $results[0]);
$this->assertEquals('2016-09-08 00:00:00', $results[0]->format('Y-m-d H:i:s'));
}
public static function provideInvalidDates()
{
return [
'invalid date' => ['Invalid DateTime Format'],
'invalid format' => ['2012-07-21', 'd.m.Y'],
'invalid ymd format' => ['2012-21-07', 'Y-m-d'],
];
}
#[DataProvider('provideInvalidDates')]
public function testRuntimeException(string $value, ?string $format = null)
{
$resolver = new DateTimeValueResolver();
$input = new ArrayInput(['created-at' => $value], new InputDefinition([
new InputArgument('created-at'),
]));
if ($format) {
$command = eval(\sprintf('return new class {
public function __invoke(
#[\\Symfony\\Component\\Console\\Attribute\\MapDateTime(format: "%s")]
\\DateTimeImmutable $createdAt
) {}
};', $format));
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
} else {
$function = static fn (\DateTimeImmutable $createdAt) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
}
$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Invalid date given for parameter "$createdAt".');
$resolver->resolve('createdAt', $input, $member);
}
}
class FooDateTime extends \DateTimeImmutable
{
}

View File

@@ -0,0 +1,80 @@
<?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\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\DefaultValueResolver;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\ArrayInput;
class DefaultValueResolverTest extends TestCase
{
public function testResolveParameterWithDefaultValue()
{
$resolver = new DefaultValueResolver();
$input = new ArrayInput([]);
$function = static fn (string $name = 'default') => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('name', $input, $member);
$this->assertSame(['default'], $result);
}
public function testResolveNullableParameterWithoutDefaultValue()
{
$resolver = new DefaultValueResolver();
$input = new ArrayInput([]);
$function = static fn (?string $name) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('name', $input, $member);
$this->assertSame([null], $result);
}
public function testResolveVariadicParameterReturnsEmpty()
{
$resolver = new DefaultValueResolver();
$input = new ArrayInput([]);
$function = static fn (string ...$names) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('names', $input, $member);
$this->assertSame([], $result);
}
public function testResolveRequiredParameterWithoutDefaultReturnsEmpty()
{
$resolver = new DefaultValueResolver();
$input = new ArrayInput([]);
$function = static fn (string $name) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('name', $input, $member);
$this->assertSame([], $result);
}
}

View File

@@ -0,0 +1,150 @@
<?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\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\BackedEnumValueResolver;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\BuiltinTypeValueResolver;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\DateTimeValueResolver;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\MapInputValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\MapInput;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
class MapInputValueResolverTest extends TestCase
{
public function testResolveMapInput()
{
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver());
$input = new ArrayInput(['username' => 'john', '--email' => 'john@example.com'], new InputDefinition([
new InputArgument('username'),
new InputOption('email'),
]));
$command = new class {
public function __invoke(
#[MapInput]
DummyInput $input,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('input', $input, $member);
$this->assertCount(1, $result);
$this->assertInstanceOf(DummyInput::class, $result[0]);
$this->assertSame('john', $result[0]->username);
$this->assertSame('john@example.com', $result[0]->email);
}
public function testDoesNotResolveWithoutAttribute()
{
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver());
$input = new ArrayInput(['username' => 'john'], new InputDefinition([
new InputArgument('username'),
]));
$function = static fn (string $username) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('username', $input, $member);
$this->assertSame([], $result);
}
public function testDoesNotResolveBuiltinTypes()
{
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver());
$input = new ArrayInput(['count' => '5'], new InputDefinition([
new InputArgument('count'),
]));
$function = static fn (int $count) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('count', $input, $member);
$this->assertSame([], $result);
}
public function testResolvesDateTimeAndBackedEnum()
{
$resolver = new MapInputValueResolver(new BuiltinTypeValueResolver(), new BackedEnumValueResolver(), new DateTimeValueResolver());
$input = new ArrayInput([
'created-at' => '2024-01-15',
'--status' => 'active',
], new InputDefinition([
new InputArgument('created-at'),
new InputOption('status'),
]));
$command = new class {
public function __invoke(
#[MapInput]
DummyInputWithDateTimeAndEnum $input,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('input', $input, $member);
$this->assertCount(1, $result);
$this->assertInstanceOf(DummyInputWithDateTimeAndEnum::class, $result[0]);
$this->assertInstanceOf(\DateTimeImmutable::class, $result[0]->createdAt);
$this->assertSame('2024-01-15', $result[0]->createdAt->format('Y-m-d'));
$this->assertSame(DummyStatus::Active, $result[0]->status);
}
}
class DummyInput
{
#[Argument]
public string $username;
#[Option]
public ?string $email = null;
}
class DummyInputWithDateTimeAndEnum
{
#[Argument]
public \DateTimeImmutable $createdAt;
#[Option]
public DummyStatus $status = DummyStatus::Pending;
}
enum DummyStatus: string
{
case Pending = 'pending';
case Active = 'active';
case Inactive = 'inactive';
}

View File

@@ -0,0 +1,212 @@
<?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\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\ArgumentResolver\Exception\NearMissValueResolverException;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\ServiceValueResolver;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\DependencyInjection\ServiceLocator;
class ServiceValueResolverTest extends TestCase
{
public function testDoNotSupportWhenCommandDoesNotExist()
{
$resolver = new ServiceValueResolver(new ServiceLocator([]));
$input = new ArrayInput(['app:test'], new InputDefinition([
new InputArgument('command'),
]));
$function = static fn (DummyService $dummy) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$this->assertSame([], $resolver->resolve('dummy', $input, $member));
}
public function testExistingCommand()
{
$resolver = new ServiceValueResolver(new ServiceLocator([
'app:test' => static fn () => new ServiceLocator([
'dummy' => static fn () => new DummyService(),
]),
]));
$input = new ArrayInput(['app:test'], new InputDefinition([
new InputArgument('command'),
]));
$function = static fn (DummyService $dummy) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('dummy', $input, $member);
$this->assertEquals([new DummyService()], $result);
}
public function testServiceLocatorPatternTakesPriorityOverTypeResolution()
{
$serviceA = new DummyService();
$serviceB = new DummyService();
$resolver = new ServiceValueResolver(new ServiceLocator([
'app:test' => static fn () => new ServiceLocator([
'dummy' => static fn () => $serviceA,
]),
DummyService::class => static fn () => $serviceB,
]));
$input = new ArrayInput(['app:test'], new InputDefinition([
new InputArgument('command'),
]));
$function = static fn (DummyService $dummy) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('dummy', $input, $member);
$this->assertSame([$serviceA], $result);
}
public function testFallbackToTypeBasedResolution()
{
$service = new DummyService();
$resolver = new ServiceValueResolver(new ServiceLocator([
DummyService::class => static fn () => $service,
]));
$input = new ArrayInput(['app:test'], new InputDefinition([
new InputArgument('command'),
]));
$function = static fn (DummyService $dummy) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('dummy', $input, $member);
$this->assertSame([$service], $result);
}
public function testTypeResolutionReturnsEmptyForBuiltinTypes()
{
$resolver = new ServiceValueResolver(new ServiceLocator([]));
$input = new ArrayInput(['app:test'], new InputDefinition([
new InputArgument('command'),
]));
$function = static fn (string $name) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('name', $input, $member);
$this->assertSame([], $result);
}
public function testTypeResolutionReturnsEmptyWhenServiceDoesNotExist()
{
$resolver = new ServiceValueResolver(new ServiceLocator([]));
$input = new ArrayInput(['app:test'], new InputDefinition([
new InputArgument('command'),
]));
$function = static fn (DummyService $dummy) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('dummy', $input, $member);
$this->assertSame([], $result);
}
public function testThrowsNearMissExceptionWhenServiceExistsButWrongType()
{
$this->expectException(NearMissValueResolverException::class);
$this->expectExceptionMessage('Service "Symfony\Component\Console\Tests\ArgumentResolver\ValueResolver\DummyService" exists in the container but is not an instance of "Symfony\Component\Console\Tests\ArgumentResolver\ValueResolver\DummyService".');
$resolver = new ServiceValueResolver(new ServiceLocator([
DummyService::class => static fn () => new \stdClass(),
]));
$input = new ArrayInput(['app:test'], new InputDefinition([
new InputArgument('command'),
]));
$function = static fn (DummyService $dummy) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
iterator_to_array($resolver->resolve('dummy', $input, $member));
}
public function testThrowsNearMissExceptionOnServiceLocatorError()
{
$this->expectException(NearMissValueResolverException::class);
$resolver = new ServiceValueResolver(new ServiceLocator([
'app:test' => static fn () => new ServiceLocator([
'dummy' => static fn () => throw new \RuntimeException('Service initialization failed'),
]),
]));
$input = new ArrayInput(['app:test'], new InputDefinition([
new InputArgument('command'),
]));
$function = static fn (DummyService $dummy) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
iterator_to_array($resolver->resolve('dummy', $input, $member));
}
public function testDoesNotResolveWhenNoCommandArgument()
{
$resolver = new ServiceValueResolver(new ServiceLocator([
'app:test' => static fn () => new ServiceLocator([
'dummy' => static fn () => new DummyService(),
]),
]));
$input = new ArrayInput([], new InputDefinition([]));
$function = static fn (DummyService $dummy) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = $resolver->resolve('dummy', $input, $member);
$this->assertSame([], $result);
}
}
class DummyService
{
}

View File

@@ -0,0 +1,253 @@
<?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\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\UidValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Exception\InvalidOptionException;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Uid\Ulid;
use Symfony\Component\Uid\Uuid;
use Symfony\Component\Uid\UuidV4;
class UidValueResolverTest extends TestCase
{
public function testResolveUuidArgument()
{
$resolver = new UidValueResolver();
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$input = new ArrayInput(['id' => $uuid], new InputDefinition([
new InputArgument('id'),
]));
$command = new class {
public function __invoke(
#[Argument]
Uuid $id,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('id', $input, $member));
$this->assertCount(1, $result);
$this->assertInstanceOf(Uuid::class, $result[0]);
$this->assertSame($uuid, (string) $result[0]);
}
public function testResolveUlidArgument()
{
$resolver = new UidValueResolver();
$ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV';
$input = new ArrayInput(['id' => $ulid], new InputDefinition([
new InputArgument('id'),
]));
$command = new class {
public function __invoke(
#[Argument]
Ulid $id,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('id', $input, $member));
$this->assertCount(1, $result);
$this->assertInstanceOf(Ulid::class, $result[0]);
$this->assertSame($ulid, (string) $result[0]);
}
public function testResolveUuidOption()
{
$resolver = new UidValueResolver();
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$input = new ArrayInput(['--id' => $uuid], new InputDefinition([
new InputOption('id', null, InputOption::VALUE_REQUIRED),
]));
$command = new class {
public function __invoke(
#[Option]
?Uuid $id = null,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('id', $input, $member));
$this->assertCount(1, $result);
$this->assertInstanceOf(Uuid::class, $result[0]);
$this->assertSame($uuid, (string) $result[0]);
}
public function testArgumentThrowsOnInvalidUid()
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('The uid for the "id" argument is invalid.');
$resolver = new UidValueResolver();
$input = new ArrayInput(['id' => 'not-a-valid-uuid'], new InputDefinition([
new InputArgument('id'),
]));
$command = new class {
public function __invoke(
#[Argument]
Uuid $id,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
iterator_to_array($resolver->resolve('id', $input, $member));
}
public function testOptionThrowsOnInvalidUid()
{
$this->expectException(InvalidOptionException::class);
$this->expectExceptionMessage('The uid for the "--id" option is invalid.');
$resolver = new UidValueResolver();
$input = new ArrayInput(['--id' => 'not-a-valid-uuid'], new InputDefinition([
new InputOption('id', null, InputOption::VALUE_REQUIRED),
]));
$command = new class {
public function __invoke(
#[Option]
?Uuid $id = null,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
iterator_to_array($resolver->resolve('id', $input, $member));
}
public function testReturnsNullWhenArgumentIsNull()
{
$resolver = new UidValueResolver();
$input = new ArrayInput(['id' => null], new InputDefinition([
new InputArgument('id', InputArgument::OPTIONAL),
]));
$command = new class {
public function __invoke(
#[Argument]
?Uuid $id = null,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('id', $input, $member));
$this->assertSame([null], $result);
}
public function testDoesNotResolveNonUidType()
{
$resolver = new UidValueResolver();
$input = new ArrayInput(['id' => '123'], new InputDefinition([
new InputArgument('id'),
]));
$command = new class {
public function __invoke(
#[Argument]
string $id,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('id', $input, $member));
$this->assertSame([], $result);
}
public function testDoesNotResolveWithoutAttribute()
{
$resolver = new UidValueResolver();
$input = new ArrayInput(['id' => '550e8400-e29b-41d4-a716-446655440000'], new InputDefinition([
new InputArgument('id'),
]));
$function = static fn (Uuid $id) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('id', $input, $member));
$this->assertSame([], $result);
}
public function testResolveSpecificUuidVersion()
{
$resolver = new UidValueResolver();
$uuid = '550e8400-e29b-41d4-a716-446655440000';
$input = new ArrayInput(['id' => $uuid], new InputDefinition([
new InputArgument('id'),
]));
$command = new class {
public function __invoke(
#[Argument]
UuidV4 $id,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('id', $input, $member));
$this->assertCount(1, $result);
$this->assertInstanceOf(UuidV4::class, $result[0]);
}
}

View File

@@ -0,0 +1,189 @@
<?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\Tests\ArgumentResolver\ValueResolver;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\VariadicValueResolver;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputDefinition;
use Symfony\Component\Console\Input\InputOption;
class VariadicValueResolverTest extends TestCase
{
public function testResolveVariadicArgument()
{
$resolver = new VariadicValueResolver();
$input = new ArrayInput(['files' => ['file1.txt', 'file2.txt', 'file3.txt']], new InputDefinition([
new InputArgument('files', InputArgument::IS_ARRAY),
]));
$command = new class {
public function __invoke(
#[Argument]
string ...$files,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('files', $input, $member));
$this->assertSame(['file1.txt', 'file2.txt', 'file3.txt'], $result);
}
public function testResolveVariadicOption()
{
$resolver = new VariadicValueResolver();
$input = new ArrayInput(['--tags' => ['foo', 'bar', 'baz']], new InputDefinition([
new InputOption('tags', null, InputOption::VALUE_IS_ARRAY | InputOption::VALUE_REQUIRED),
]));
$command = new class {
public function __invoke(
#[Option]
string ...$tags,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('tags', $input, $member));
$this->assertSame(['foo', 'bar', 'baz'], $result);
}
public function testResolveEmptyVariadicArgument()
{
$resolver = new VariadicValueResolver();
$input = new ArrayInput(['files' => []], new InputDefinition([
new InputArgument('files', InputArgument::IS_ARRAY | InputArgument::OPTIONAL),
]));
$command = new class {
public function __invoke(
#[Argument]
string ...$files,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('files', $input, $member));
$this->assertSame([], $result);
}
public function testDoesNotResolveNonVariadicParameter()
{
$resolver = new VariadicValueResolver();
$input = new ArrayInput(['name' => 'john'], new InputDefinition([
new InputArgument('name'),
]));
$command = new class {
public function __invoke(
#[Argument]
string $name,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('name', $input, $member));
$this->assertSame([], $result);
}
public function testDoesNotResolveWithoutAttribute()
{
$resolver = new VariadicValueResolver();
$input = new ArrayInput(['files' => ['file1.txt', 'file2.txt']], new InputDefinition([
new InputArgument('files', InputArgument::IS_ARRAY),
]));
$function = static fn (string ...$files) => null;
$reflection = new \ReflectionFunction($function);
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
$result = iterator_to_array($resolver->resolve('files', $input, $member));
$this->assertSame([], $result);
}
public function testThrowsWhenArgumentValueIsNotArray()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The action argument "...$files" is required to be an array');
$resolver = new VariadicValueResolver();
$input = new ArrayInput(['files' => 'single-value'], new InputDefinition([
new InputArgument('files'),
]));
$command = new class {
public function __invoke(
#[Argument]
string ...$files,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
iterator_to_array($resolver->resolve('files', $input, $member));
}
public function testThrowsWhenOptionValueIsNotArray()
{
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('The action argument "...$tags" is required to be an array');
$resolver = new VariadicValueResolver();
$input = new ArrayInput(['--tags' => 'single-value'], new InputDefinition([
new InputOption('tags', null, InputOption::VALUE_REQUIRED),
]));
$command = new class {
public function __invoke(
#[Option]
string ...$tags,
) {
}
};
$reflection = new \ReflectionMethod($command, '__invoke');
$parameter = $reflection->getParameters()[0];
$member = new ReflectionMember($parameter);
iterator_to_array($resolver->resolve('tags', $input, $member));
}
}

View File

@@ -15,8 +15,11 @@ use PHPUnit\Framework\Assert;
use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\ArgumentResolver\ArgumentResolver;
use Symfony\Component\Console\ArgumentResolver\ValueResolver\ValueResolverInterface;
use Symfony\Component\Console\Attribute\Argument;
use Symfony\Component\Console\Attribute\Option;
use Symfony\Component\Console\Attribute\Reflection\ReflectionMember;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Completion\CompletionInput;
use Symfony\Component\Console\Completion\CompletionSuggestions;
@@ -30,6 +33,7 @@ use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\NullOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Style\SymfonyStyle;
use Symfony\Component\Console\Tester\CommandTester;
use Symfony\Component\Console\Tests\Fixtures\InvokableTestCommand;
class InvokableCommandTest extends TestCase
@@ -230,26 +234,6 @@ class InvokableCommandTest extends TestCase
$command->run(new ArrayInput(['--enum' => 'incorrect']), new NullOutput());
}
public function testInvalidArgumentType()
{
$command = new Command('foo');
$command->setCode(static function (#[Argument] object $any) {});
$this->expectException(LogicException::class);
$command->getDefinition();
}
public function testInvalidOptionType()
{
$command = new Command('foo');
$command->setCode(static function (#[Option] ?object $any = null) {});
$this->expectException(LogicException::class);
$command->getDefinition();
}
public function testExecuteHasPriorityOverInvokeMethod()
{
$command = new class extends Command {
@@ -494,6 +478,79 @@ class InvokableCommandTest extends TestCase
$command->run(new ArrayInput([]), new NullOutput());
}
public function testDefaultArgumentResolversWithoutApplication()
{
$command = new Command('foo');
$command->setCode(static function (
\DateTime $date,
#[Argument] string $name = 'default',
): int {
Assert::assertInstanceOf(\DateTime::class, $date);
Assert::assertSame('test', $name);
return 0;
});
$tester = new CommandTester($command);
$tester->execute(['name' => 'test']);
$tester->assertCommandIsSuccessful();
}
public function testCustomArgumentResolverViaApplication()
{
$customArgumentResolver = new ArgumentResolver([
new CustomTypeValueResolver(),
...ArgumentResolver::getDefaultArgumentValueResolvers(),
]);
$application = new Application();
$application->setArgumentResolver($customArgumentResolver);
$command = new Command('foo');
$command->setCode(static function (
CustomType $custom,
#[Argument] string $name = 'default',
): int {
Assert::assertInstanceOf(CustomType::class, $custom);
Assert::assertSame('resolved:from-app-test', $custom->value);
Assert::assertSame('app-test', $name);
return 0;
});
$application->addCommand($command);
$tester = new CommandTester($command);
$tester->execute(['name' => 'app-test']);
$tester->assertCommandIsSuccessful();
}
public function testCommandInjection()
{
$application = new Application();
$command = new Command('test-cmd');
$command->setCode(static function (
Command $cmd,
#[Argument] string $arg = 'default',
): int {
Assert::assertInstanceOf(Command::class, $cmd);
Assert::assertSame('test-cmd', $cmd->getName());
Assert::assertSame('value', $arg);
return 0;
});
$application->addCommand($command);
$tester = new CommandTester($command);
$tester->execute(['arg' => 'value']);
$tester->assertCommandIsSuccessful();
}
public function getSuggestedRoles(CompletionInput $input): array
{
return ['ROLE_ADMIN', 'ROLE_USER'];
@@ -505,3 +562,26 @@ enum StringEnum: string
case Image = 'image';
case Video = 'video';
}
class CustomType
{
public function __construct(public string $value)
{
}
}
class CustomTypeValueResolver implements ValueResolverInterface
{
public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable
{
$type = $member->getType();
if (!$type instanceof \ReflectionNamedType || CustomType::class !== $type->getName()) {
return [];
}
$name = $input->hasArgument('name') ? $input->getArgument('name') : 'default';
yield new CustomType('resolved:from-'.$name);
}
}

View File

@@ -0,0 +1,196 @@
<?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\Tests\DependencyInjection;
use PHPUnit\Framework\TestCase;
use Psr\Log\LoggerInterface;
use Symfony\Component\Console\DependencyInjection\RegisterCommandArgumentLocatorsPass;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
class RegisterCommandArgumentLocatorsPassTest extends TestCase
{
public function testProcessWithoutServiceResolver()
{
$container = new ContainerBuilder();
$pass = new RegisterCommandArgumentLocatorsPass();
$pass->process($container);
$this->assertTrue(true);
}
public function testProcessWithServiceArguments()
{
$container = new ContainerBuilder();
$container->register('logger', LoggerInterface::class);
$container->register('console.argument_resolver.service')->addArgument(null);
$command = new Definition(CommandWithServiceArguments::class);
$command->setAutowired(true);
$command->addTag('console.command', ['command' => 'test:command']);
$command->addTag('console.command.service_arguments');
$container->setDefinition('test.command', $command);
$pass = new RegisterCommandArgumentLocatorsPass();
$pass->process($container);
$serviceResolverDef = $container->getDefinition('console.argument_resolver.service');
$commandLocatorRef = $serviceResolverDef->getArgument(0);
$this->assertInstanceOf(Reference::class, $commandLocatorRef);
$commandLocator = $container->getDefinition((string) $commandLocatorRef);
$this->assertSame(ServiceLocator::class, $commandLocator->getClass());
$commands = $commandLocator->getArgument(0);
$this->assertArrayHasKey('test:command', $commands);
}
public function testProcessWithManualArgumentMapping()
{
$container = new ContainerBuilder();
$container->register('my.logger', LoggerInterface::class);
$container->register('console.argument_resolver.service')->addArgument(null);
$command = new Definition(CommandWithServiceArguments::class);
$command->addTag('console.command', ['command' => 'test:command']);
$command->addTag('console.command.service_arguments', [
'argument' => 'logger',
'id' => 'my.logger',
]);
$container->setDefinition('test.command', $command);
$pass = new RegisterCommandArgumentLocatorsPass();
$pass->process($container);
$serviceResolverDef = $container->getDefinition('console.argument_resolver.service');
$commandLocatorRef = $serviceResolverDef->getArgument(0);
$commandLocator = $container->getDefinition((string) $commandLocatorRef);
$commands = $commandLocator->getArgument(0);
$this->assertArrayHasKey('test:command', $commands);
}
public function testProcessSkipsInputOutputParameters()
{
$container = new ContainerBuilder();
$container->register('console.argument_resolver.service')->addArgument(null);
$command = new Definition(CommandWithInputOutput::class);
$command->setAutowired(true);
$command->addTag('console.command', ['command' => 'test:command']);
$command->addTag('console.command.service_arguments');
$container->setDefinition('test.command', $command);
$pass = new RegisterCommandArgumentLocatorsPass();
$pass->process($container);
$serviceResolverDef = $container->getDefinition('console.argument_resolver.service');
$commandLocatorRef = $serviceResolverDef->getArgument(0);
$commandLocator = $container->getDefinition((string) $commandLocatorRef);
$commands = $commandLocator->getArgument(0);
$this->assertArrayNotHasKey('test:command', $commands);
}
public function testProcessWithMultipleMethods()
{
$container = new ContainerBuilder();
$container->register('logger', LoggerInterface::class);
$container->register('console.argument_resolver.service')->addArgument(null);
$command = new Definition(MultiMethodCommand::class);
$command->setAutowired(true);
$command->addTag('console.command', ['command' => 'test:cmd1', 'method' => 'cmd1']);
$command->addTag('console.command', ['command' => 'test:cmd2', 'method' => 'cmd2']);
$command->addTag('console.command.service_arguments');
$container->setDefinition('test.command', $command);
$pass = new RegisterCommandArgumentLocatorsPass();
$pass->process($container);
$serviceResolverDef = $container->getDefinition('console.argument_resolver.service');
$commandLocatorRef = $serviceResolverDef->getArgument(0);
$commandLocator = $container->getDefinition((string) $commandLocatorRef);
$commands = $commandLocator->getArgument(0);
$this->assertArrayHasKey('test:cmd1', $commands);
$this->assertArrayHasKey('test:cmd2', $commands);
}
public function testProcessThrowsOnMissingArgumentAttribute()
{
$container = new ContainerBuilder();
$container->register('console.argument_resolver.service')->addArgument(null);
$command = new Definition(CommandWithServiceArguments::class);
$command->addTag('console.command', ['command' => 'test:command']);
$command->addTag('console.command.service_arguments', ['argument' => 'logger']);
$container->setDefinition('test.command', $command);
$pass = new RegisterCommandArgumentLocatorsPass();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing "id" attribute');
$pass->process($container);
}
public function testProcessThrowsOnMissingIdAttribute()
{
$container = new ContainerBuilder();
$container->register('console.argument_resolver.service')->addArgument(null);
$command = new Definition(CommandWithServiceArguments::class);
$command->addTag('console.command', ['command' => 'test:command']);
$command->addTag('console.command.service_arguments', ['id' => 'my.logger']);
$container->setDefinition('test.command', $command);
$pass = new RegisterCommandArgumentLocatorsPass();
$this->expectException(\InvalidArgumentException::class);
$this->expectExceptionMessage('Missing "argument" attribute');
$pass->process($container);
}
}
class CommandWithServiceArguments
{
public function __invoke(LoggerInterface $logger): void
{
}
}
class CommandWithInputOutput
{
public function __invoke(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output): void
{
}
}
class MultiMethodCommand
{
public function cmd1(LoggerInterface $logger): void
{
}
public function cmd2(LoggerInterface $logger): void
{
}
}

View File

@@ -0,0 +1,129 @@
<?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\Tests\DependencyInjection;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Console\DependencyInjection\RemoveEmptyCommandArgumentLocatorsPass;
use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\DependencyInjection\ServiceLocator;
class RemoveEmptyCommandArgumentLocatorsPassTest extends TestCase
{
public function testProcessWithoutServiceResolver()
{
$container = new ContainerBuilder();
$pass = new RemoveEmptyCommandArgumentLocatorsPass();
$pass->process($container);
$this->assertTrue(true);
}
public function testProcessWithNullArgument()
{
$container = new ContainerBuilder();
$container->register('console.argument_resolver.service')->addArgument(null);
$pass = new RemoveEmptyCommandArgumentLocatorsPass();
$pass->process($container);
$this->assertTrue(true);
}
public function testProcessRemovesEmptyLocators()
{
$container = new ContainerBuilder();
$emptyArgumentLocator = new Definition(ServiceLocator::class, [[]]);
$container->setDefinition('empty_argument_locator', $emptyArgumentLocator);
$nonEmptyArgumentLocator = new Definition(ServiceLocator::class, [['service' => new Reference('some.service')]]);
$container->setDefinition('non_empty_argument_locator', $nonEmptyArgumentLocator);
$commandLocator = new Definition(ServiceLocator::class, [[
'empty:command' => new ServiceClosureArgument(new Reference('empty_argument_locator')),
'non-empty:command' => new ServiceClosureArgument(new Reference('non_empty_argument_locator')),
]]);
$container->setDefinition('command_locator', $commandLocator);
$serviceResolver = new Definition('stdClass', [new Reference('command_locator')]);
$container->setDefinition('console.argument_resolver.service', $serviceResolver);
$pass = new RemoveEmptyCommandArgumentLocatorsPass();
$pass->process($container);
$commandLocatorDef = $container->getDefinition('command_locator');
$commands = $commandLocatorDef->getArgument(0);
$this->assertArrayNotHasKey('empty:command', $commands);
$this->assertArrayHasKey('non-empty:command', $commands);
}
public function testProcessWithFactory()
{
$container = new ContainerBuilder();
$emptyArgumentLocator = new Definition(ServiceLocator::class, [[]]);
$container->setDefinition('empty_argument_locator_inner', $emptyArgumentLocator);
$emptyArgumentLocatorWrapper = new Definition(ServiceLocator::class);
$emptyArgumentLocatorWrapper->setFactory([new Reference('empty_argument_locator_inner'), 'getInstance']);
$container->setDefinition('empty_argument_locator', $emptyArgumentLocatorWrapper);
$commandLocator = new Definition(ServiceLocator::class, [[
'empty:command' => new ServiceClosureArgument(new Reference('empty_argument_locator')),
]]);
$container->setDefinition('command_locator_inner', $commandLocator);
$commandLocatorWrapper = new Definition(ServiceLocator::class);
$commandLocatorWrapper->setFactory([new Reference('command_locator_inner'), 'getInstance']);
$container->setDefinition('command_locator', $commandLocatorWrapper);
$serviceResolver = new Definition('stdClass', [new Reference('command_locator')]);
$container->setDefinition('console.argument_resolver.service', $serviceResolver);
$pass = new RemoveEmptyCommandArgumentLocatorsPass();
$pass->process($container);
$commandLocatorDef = $container->getDefinition('command_locator_inner');
$commands = $commandLocatorDef->getArgument(0);
$this->assertArrayNotHasKey('empty:command', $commands);
}
public function testProcessPreservesNonEmptyLocators()
{
$container = new ContainerBuilder();
$argumentLocator = new Definition(ServiceLocator::class, [['logger' => new Reference('logger')]]);
$container->setDefinition('argument_locator', $argumentLocator);
$commandLocator = new Definition(ServiceLocator::class, [[
'test:command' => new ServiceClosureArgument(new Reference('argument_locator')),
]]);
$container->setDefinition('command_locator', $commandLocator);
$serviceResolver = new Definition('stdClass', [new Reference('command_locator')]);
$container->setDefinition('console.argument_resolver.service', $serviceResolver);
$pass = new RemoveEmptyCommandArgumentLocatorsPass();
$pass->process($container);
$commandLocatorDef = $container->getDefinition('command_locator');
$commands = $commandLocatorDef->getArgument(0);
$this->assertArrayHasKey('test:command', $commands);
}
}

View File

@@ -32,6 +32,7 @@
"symfony/messenger": "^7.4|^8.0",
"symfony/process": "^7.4|^8.0",
"symfony/stopwatch": "^7.4|^8.0",
"symfony/uid": "^7.4|^8.0",
"symfony/var-dumper": "^7.4|^8.0"
},
"provide": {