From 6a35416845fcfb7ef581dfd139edcd5513b82dd0 Mon Sep 17 00:00:00 2001 From: Robin Chalas Date: Mon, 13 Oct 2025 13:23:30 +0200 Subject: [PATCH] [Console] Add argument resolvers --- Application.php | 15 + ArgumentResolver/ArgumentResolver.php | 159 ++++++++ .../ArgumentResolverInterface.php | 33 ++ .../NearMissValueResolverException.php | 21 + .../Exception/ResolverNotFoundException.php | 33 ++ .../ValueResolver/BackedEnumValueResolver.php | 90 +++++ .../BuiltinTypeValueResolver.php | 75 ++++ .../ValueResolver/DateTimeValueResolver.php | 102 +++++ .../ValueResolver/DefaultValueResolver.php | 37 ++ .../ValueResolver/MapInputValueResolver.php | 89 +++++ .../ValueResolver/ServiceValueResolver.php | 81 ++++ .../ValueResolver/UidValueResolver.php | 87 +++++ .../ValueResolver/ValueResolverInterface.php | 30 ++ .../ValueResolver/VariadicValueResolver.php | 54 +++ Attribute/Argument.php | 38 +- Attribute/AsTargetedValueResolver.php | 26 ++ Attribute/MapDateTime.php | 36 ++ Attribute/MapInput.php | 92 ++++- Attribute/Option.php | 62 +-- Attribute/Reflection/ReflectionMember.php | 32 ++ Attribute/ValueResolver.php | 31 ++ CHANGELOG.md | 2 + Command/InvokableCommand.php | 88 +++-- .../RegisterCommandArgumentLocatorsPass.php | 189 +++++++++ ...RemoveEmptyCommandArgumentLocatorsPass.php | 61 +++ Interaction/Interaction.php | 2 +- .../BackedEnumValueResolverTest.php | 227 +++++++++++ .../BuiltinTypeValueResolverTest.php | 268 +++++++++++++ .../DateTimeValueResolverTest.php | 366 ++++++++++++++++++ .../DefaultValueResolverTest.php | 80 ++++ .../MapInputValueResolverTest.php | 150 +++++++ .../ServiceValueResolverTest.php | 212 ++++++++++ .../ValueResolver/UidValueResolverTest.php | 253 ++++++++++++ .../VariadicValueResolverTest.php | 189 +++++++++ Tests/Command/InvokableCommandTest.php | 120 +++++- ...egisterCommandArgumentLocatorsPassTest.php | 196 ++++++++++ ...veEmptyCommandArgumentLocatorsPassTest.php | 129 ++++++ composer.json | 1 + 38 files changed, 3604 insertions(+), 152 deletions(-) create mode 100644 ArgumentResolver/ArgumentResolver.php create mode 100644 ArgumentResolver/ArgumentResolverInterface.php create mode 100644 ArgumentResolver/Exception/NearMissValueResolverException.php create mode 100644 ArgumentResolver/Exception/ResolverNotFoundException.php create mode 100644 ArgumentResolver/ValueResolver/BackedEnumValueResolver.php create mode 100644 ArgumentResolver/ValueResolver/BuiltinTypeValueResolver.php create mode 100644 ArgumentResolver/ValueResolver/DateTimeValueResolver.php create mode 100644 ArgumentResolver/ValueResolver/DefaultValueResolver.php create mode 100644 ArgumentResolver/ValueResolver/MapInputValueResolver.php create mode 100644 ArgumentResolver/ValueResolver/ServiceValueResolver.php create mode 100644 ArgumentResolver/ValueResolver/UidValueResolver.php create mode 100644 ArgumentResolver/ValueResolver/ValueResolverInterface.php create mode 100644 ArgumentResolver/ValueResolver/VariadicValueResolver.php create mode 100644 Attribute/AsTargetedValueResolver.php create mode 100644 Attribute/MapDateTime.php create mode 100644 Attribute/ValueResolver.php create mode 100644 DependencyInjection/RegisterCommandArgumentLocatorsPass.php create mode 100644 DependencyInjection/RemoveEmptyCommandArgumentLocatorsPass.php create mode 100644 Tests/ArgumentResolver/ValueResolver/BackedEnumValueResolverTest.php create mode 100644 Tests/ArgumentResolver/ValueResolver/BuiltinTypeValueResolverTest.php create mode 100644 Tests/ArgumentResolver/ValueResolver/DateTimeValueResolverTest.php create mode 100644 Tests/ArgumentResolver/ValueResolver/DefaultValueResolverTest.php create mode 100644 Tests/ArgumentResolver/ValueResolver/MapInputValueResolverTest.php create mode 100644 Tests/ArgumentResolver/ValueResolver/ServiceValueResolverTest.php create mode 100644 Tests/ArgumentResolver/ValueResolver/UidValueResolverTest.php create mode 100644 Tests/ArgumentResolver/ValueResolver/VariadicValueResolverTest.php create mode 100644 Tests/DependencyInjection/RegisterCommandArgumentLocatorsPassTest.php create mode 100644 Tests/DependencyInjection/RemoveEmptyCommandArgumentLocatorsPassTest.php diff --git a/Application.php b/Application.php index ccddaebc..e8f4c205 100644 --- a/Application.php +++ b/Application.php @@ -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; diff --git a/ArgumentResolver/ArgumentResolver.php b/ArgumentResolver/ArgumentResolver.php new file mode 100644 index 00000000..a39adf18 --- /dev/null +++ b/ArgumentResolver/ArgumentResolver.php @@ -0,0 +1,159 @@ + + * + * 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 + */ +final class ArgumentResolver implements ArgumentResolverInterface +{ + /** + * @param iterable $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 + */ + 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(), + ]; + } +} diff --git a/ArgumentResolver/ArgumentResolverInterface.php b/ArgumentResolver/ArgumentResolverInterface.php new file mode 100644 index 00000000..b734abab --- /dev/null +++ b/ArgumentResolver/ArgumentResolverInterface.php @@ -0,0 +1,33 @@ + + * + * 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 + * @author Fabien Potencier + * @author Nicolas Grekas + */ +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; +} diff --git a/ArgumentResolver/Exception/NearMissValueResolverException.php b/ArgumentResolver/Exception/NearMissValueResolverException.php new file mode 100644 index 00000000..34a22e35 --- /dev/null +++ b/ArgumentResolver/Exception/NearMissValueResolverException.php @@ -0,0 +1,21 @@ + + * + * 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 +{ +} diff --git a/ArgumentResolver/Exception/ResolverNotFoundException.php b/ArgumentResolver/Exception/ResolverNotFoundException.php new file mode 100644 index 00000000..3e3797ad --- /dev/null +++ b/ArgumentResolver/Exception/ResolverNotFoundException.php @@ -0,0 +1,33 @@ + + * + * 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); + } +} diff --git a/ArgumentResolver/ValueResolver/BackedEnumValueResolver.php b/ArgumentResolver/ValueResolver/BackedEnumValueResolver.php new file mode 100644 index 00000000..7e29cf2d --- /dev/null +++ b/ArgumentResolver/ValueResolver/BackedEnumValueResolver.php @@ -0,0 +1,90 @@ + + * + * 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 + * @author Jérôme Tamarelle + * @author Maxime Steinhausser + */ +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); + } +} diff --git a/ArgumentResolver/ValueResolver/BuiltinTypeValueResolver.php b/ArgumentResolver/ValueResolver/BuiltinTypeValueResolver.php new file mode 100644 index 00000000..e6015df5 --- /dev/null +++ b/ArgumentResolver/ValueResolver/BuiltinTypeValueResolver.php @@ -0,0 +1,75 @@ + + * + * 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 + */ +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; + } +} diff --git a/ArgumentResolver/ValueResolver/DateTimeValueResolver.php b/ArgumentResolver/ValueResolver/DateTimeValueResolver.php new file mode 100644 index 00000000..be263c5e --- /dev/null +++ b/ArgumentResolver/ValueResolver/DateTimeValueResolver.php @@ -0,0 +1,102 @@ + + * + * 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 + * @author Tim Goudriaan + * @author Robin Chalas + */ +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]; + } +} diff --git a/ArgumentResolver/ValueResolver/DefaultValueResolver.php b/ArgumentResolver/ValueResolver/DefaultValueResolver.php new file mode 100644 index 00000000..ac8f0aac --- /dev/null +++ b/ArgumentResolver/ValueResolver/DefaultValueResolver.php @@ -0,0 +1,37 @@ + + * + * 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 + * @author Robin Chalas + */ +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 []; + } +} diff --git a/ArgumentResolver/ValueResolver/MapInputValueResolver.php b/ArgumentResolver/ValueResolver/MapInputValueResolver.php new file mode 100644 index 00000000..7fb9cbc0 --- /dev/null +++ b/ArgumentResolver/ValueResolver/MapInputValueResolver.php @@ -0,0 +1,89 @@ + + * + * 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 + * @author Robin Chalas + */ +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; + } +} diff --git a/ArgumentResolver/ValueResolver/ServiceValueResolver.php b/ArgumentResolver/ValueResolver/ServiceValueResolver.php new file mode 100644 index 00000000..1eeccb64 --- /dev/null +++ b/ArgumentResolver/ValueResolver/ServiceValueResolver.php @@ -0,0 +1,81 @@ + + * + * 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 + * @author Robin Chalas + */ +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); + } + } +} diff --git a/ArgumentResolver/ValueResolver/UidValueResolver.php b/ArgumentResolver/ValueResolver/UidValueResolver.php new file mode 100644 index 00000000..a2bf61c7 --- /dev/null +++ b/ArgumentResolver/ValueResolver/UidValueResolver.php @@ -0,0 +1,87 @@ + + * + * 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 + */ +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); + } +} diff --git a/ArgumentResolver/ValueResolver/ValueResolverInterface.php b/ArgumentResolver/ValueResolver/ValueResolverInterface.php new file mode 100644 index 00000000..09e9d089 --- /dev/null +++ b/ArgumentResolver/ValueResolver/ValueResolverInterface.php @@ -0,0 +1,30 @@ + + * + * 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 + * @author Robin Chalas + */ +interface ValueResolverInterface +{ + /** + * Returns the possible value(s) for the argument. + */ + public function resolve(string $argumentName, InputInterface $input, ReflectionMember $member): iterable; +} diff --git a/ArgumentResolver/ValueResolver/VariadicValueResolver.php b/ArgumentResolver/ValueResolver/VariadicValueResolver.php new file mode 100644 index 00000000..f90916bc --- /dev/null +++ b/ArgumentResolver/ValueResolver/VariadicValueResolver.php @@ -0,0 +1,54 @@ + + * + * 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 + */ +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 []; + } +} diff --git a/Attribute/Argument.php b/Attribute/Argument.php index 33b7a86b..8533b8fc 100644 --- a/Attribute/Argument.php +++ b/Attribute/Argument.php @@ -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 */ diff --git a/Attribute/AsTargetedValueResolver.php b/Attribute/AsTargetedValueResolver.php new file mode 100644 index 00000000..813f7073 --- /dev/null +++ b/Attribute/AsTargetedValueResolver.php @@ -0,0 +1,26 @@ + + * + * 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) + { + } +} diff --git a/Attribute/MapDateTime.php b/Attribute/MapDateTime.php new file mode 100644 index 00000000..9e2ac517 --- /dev/null +++ b/Attribute/MapDateTime.php @@ -0,0 +1,36 @@ + + * + * 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 + */ +#[\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.'); + } + } +} diff --git a/Attribute/MapInput.php b/Attribute/MapInput.php index 0b508361..6af0baa5 100644 --- a/Attribute/MapInput.php +++ b/Attribute/MapInput.php @@ -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 + */ + public function getClass(): \ReflectionClass + { + return $this->class; + } + + /** + * @internal + * + * @return array + */ + 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 * diff --git a/Attribute/Option.php b/Attribute/Option.php index 5f0f4761..c3935091 100644 --- a/Attribute/Option.php +++ b/Attribute/Option.php @@ -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( diff --git a/Attribute/Reflection/ReflectionMember.php b/Attribute/Reflection/ReflectionMember.php index eb6df115..1a3ad37c 100644 --- a/Attribute/Reflection/ReflectionMember.php +++ b/Attribute/Reflection/ReflectionMember.php @@ -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 $class + * + * @return list + */ + 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(); + } } diff --git a/Attribute/ValueResolver.php b/Attribute/ValueResolver.php new file mode 100644 index 00000000..88ddd20f --- /dev/null +++ b/Attribute/ValueResolver.php @@ -0,0 +1,31 @@ + + * + * 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|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, + ) { + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 14f24105..5965893f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 --- diff --git a/Command/InvokableCommand.php b/Command/InvokableCommand.php index 02740702..07c0d977 100644 --- a/Command/InvokableCommand.php +++ b/Command/InvokableCommand.php @@ -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 diff --git a/DependencyInjection/RegisterCommandArgumentLocatorsPass.php b/DependencyInjection/RegisterCommandArgumentLocatorsPass.php new file mode 100644 index 00000000..6a33dc53 --- /dev/null +++ b/DependencyInjection/RegisterCommandArgumentLocatorsPass.php @@ -0,0 +1,189 @@ + + * + * 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 + * @author Robin Chalas + */ +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)); + } +} diff --git a/DependencyInjection/RemoveEmptyCommandArgumentLocatorsPass.php b/DependencyInjection/RemoveEmptyCommandArgumentLocatorsPass.php new file mode 100644 index 00000000..64c9232a --- /dev/null +++ b/DependencyInjection/RemoveEmptyCommandArgumentLocatorsPass.php @@ -0,0 +1,61 @@ + + * + * 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 + */ +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); + } +} diff --git a/Interaction/Interaction.php b/Interaction/Interaction.php index 98ed899f..19119c98 100644 --- a/Interaction/Interaction.php +++ b/Interaction/Interaction.php @@ -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()); diff --git a/Tests/ArgumentResolver/ValueResolver/BackedEnumValueResolverTest.php b/Tests/ArgumentResolver/ValueResolver/BackedEnumValueResolverTest.php new file mode 100644 index 00000000..a37cac83 --- /dev/null +++ b/Tests/ArgumentResolver/ValueResolver/BackedEnumValueResolverTest.php @@ -0,0 +1,227 @@ + + * + * 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; +} diff --git a/Tests/ArgumentResolver/ValueResolver/BuiltinTypeValueResolverTest.php b/Tests/ArgumentResolver/ValueResolver/BuiltinTypeValueResolverTest.php new file mode 100644 index 00000000..ead9d01d --- /dev/null +++ b/Tests/ArgumentResolver/ValueResolver/BuiltinTypeValueResolverTest.php @@ -0,0 +1,268 @@ + + * + * 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'; +} diff --git a/Tests/ArgumentResolver/ValueResolver/DateTimeValueResolverTest.php b/Tests/ArgumentResolver/ValueResolver/DateTimeValueResolverTest.php new file mode 100644 index 00000000..74f60c2e --- /dev/null +++ b/Tests/ArgumentResolver/ValueResolver/DateTimeValueResolverTest.php @@ -0,0 +1,366 @@ + + * + * 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 +{ +} diff --git a/Tests/ArgumentResolver/ValueResolver/DefaultValueResolverTest.php b/Tests/ArgumentResolver/ValueResolver/DefaultValueResolverTest.php new file mode 100644 index 00000000..cd7a2ebb --- /dev/null +++ b/Tests/ArgumentResolver/ValueResolver/DefaultValueResolverTest.php @@ -0,0 +1,80 @@ + + * + * 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); + } +} diff --git a/Tests/ArgumentResolver/ValueResolver/MapInputValueResolverTest.php b/Tests/ArgumentResolver/ValueResolver/MapInputValueResolverTest.php new file mode 100644 index 00000000..65f438af --- /dev/null +++ b/Tests/ArgumentResolver/ValueResolver/MapInputValueResolverTest.php @@ -0,0 +1,150 @@ + + * + * 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'; +} diff --git a/Tests/ArgumentResolver/ValueResolver/ServiceValueResolverTest.php b/Tests/ArgumentResolver/ValueResolver/ServiceValueResolverTest.php new file mode 100644 index 00000000..5d8b761b --- /dev/null +++ b/Tests/ArgumentResolver/ValueResolver/ServiceValueResolverTest.php @@ -0,0 +1,212 @@ + + * + * 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 +{ +} diff --git a/Tests/ArgumentResolver/ValueResolver/UidValueResolverTest.php b/Tests/ArgumentResolver/ValueResolver/UidValueResolverTest.php new file mode 100644 index 00000000..3c68d305 --- /dev/null +++ b/Tests/ArgumentResolver/ValueResolver/UidValueResolverTest.php @@ -0,0 +1,253 @@ + + * + * 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]); + } +} diff --git a/Tests/ArgumentResolver/ValueResolver/VariadicValueResolverTest.php b/Tests/ArgumentResolver/ValueResolver/VariadicValueResolverTest.php new file mode 100644 index 00000000..6f26cf79 --- /dev/null +++ b/Tests/ArgumentResolver/ValueResolver/VariadicValueResolverTest.php @@ -0,0 +1,189 @@ + + * + * 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)); + } +} diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index a18fb8bc..349242e3 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -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); + } +} diff --git a/Tests/DependencyInjection/RegisterCommandArgumentLocatorsPassTest.php b/Tests/DependencyInjection/RegisterCommandArgumentLocatorsPassTest.php new file mode 100644 index 00000000..bfddb8ad --- /dev/null +++ b/Tests/DependencyInjection/RegisterCommandArgumentLocatorsPassTest.php @@ -0,0 +1,196 @@ + + * + * 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 + { + } +} diff --git a/Tests/DependencyInjection/RemoveEmptyCommandArgumentLocatorsPassTest.php b/Tests/DependencyInjection/RemoveEmptyCommandArgumentLocatorsPassTest.php new file mode 100644 index 00000000..3e5dc7f1 --- /dev/null +++ b/Tests/DependencyInjection/RemoveEmptyCommandArgumentLocatorsPassTest.php @@ -0,0 +1,129 @@ + + * + * 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); + } +} diff --git a/composer.json b/composer.json index f4680e83..90866c90 100644 --- a/composer.json +++ b/composer.json @@ -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": {