diff --git a/src/Symfony/Component/Console/CHANGELOG.md b/src/Symfony/Component/Console/CHANGELOG.md index b84099a1d0e10..e3ed589318023 100644 --- a/src/Symfony/Component/Console/CHANGELOG.md +++ b/src/Symfony/Component/Console/CHANGELOG.md @@ -14,6 +14,8 @@ CHANGELOG * Add support for `LockableTrait` in invokable commands * Deprecate returning a non-integer value from a `\Closure` function set via `Command::setCode()` * Mark `#[AsCommand]` attribute as `@final` + * Add `InputInterface::getRawArguments()`, `InputInterface::getRawOptions()` and `InputInterface::unparse()` methods. All are + implemented in the child abstract class `Input`. 7.2 --- diff --git a/src/Symfony/Component/Console/Input/Input.php b/src/Symfony/Component/Console/Input/Input.php index d2881c60fe90f..75e3caa1babea 100644 --- a/src/Symfony/Component/Console/Input/Input.php +++ b/src/Symfony/Component/Console/Input/Input.php @@ -13,6 +13,9 @@ use Symfony\Component\Console\Exception\InvalidArgumentException; use Symfony\Component\Console\Exception\RuntimeException; +use function array_map; +use function sprintf; +use const true; /** * Input is the base class for all concrete Input classes. @@ -85,6 +88,16 @@ public function getArguments(): array return array_merge($this->definition->getArgumentDefaults(), $this->arguments); } + /** + * Returns all the given arguments NOT merged with the default values. + * + * @return array|null> + */ + public function getRawArguments(): array + { + return $this->arguments; + } + public function getArgument(string $name): mixed { if (!$this->definition->hasArgument($name)) { @@ -113,6 +126,16 @@ public function getOptions(): array return array_merge($this->definition->getOptionDefaults(), $this->options); } + /** + * Returns all the given options NOT merged with the default values. + * + * @return array|null> + */ + public function getRawOptions(): array + { + return $this->options; + } + public function getOption(string $name): mixed { if ($this->definition->hasNegation($name)) { @@ -171,4 +194,39 @@ public function getStream() { return $this->stream; } + + /** + * Returns a stringified representation of the options passed to the command. + * The options must NOT be escaped as otherwise passing them to a `Process` would result in them being escaped twice. + * + * @param string[] $optionNames Names of the options returned. If null, all options are returned. Requested options + * that either do not exist or were not passed (even if the option has a default value) + * will not be part of the method output. + * + * @return list + */ + public function unparse(?array $optionNames = null): array + { + $rawOptions = $this->getRawOptions(); + + $filteredRawOptions = null === $optionNames + ? $rawOptions + : array_intersect_key($rawOptions, array_fill_keys($optionNames, '')); + + $unparsedOptions = []; + + foreach ($filteredRawOptions as $optionName => $parsedOption) { + $option = $this->definition->getOption($optionName); + + $unparsedOptions[] = match (true) { + $option->isNegatable() => [sprintf('--%s%s', $parsedOption ? '' : 'no-', $optionName)], + !$option->acceptValue() => [sprintf('--%s', $optionName,)], + $option->isArray() => array_map(static fn($item,) => sprintf('--%s=%s', $optionName, $item), $parsedOption), + default => [sprintf('--%s=%s', $optionName, $parsedOption)], + }; + } + + return array_merge(...$unparsedOptions); + } + } diff --git a/src/Symfony/Component/Console/Input/InputInterface.php b/src/Symfony/Component/Console/Input/InputInterface.php index c177d960bce33..12a1cd78a89cd 100644 --- a/src/Symfony/Component/Console/Input/InputInterface.php +++ b/src/Symfony/Component/Console/Input/InputInterface.php @@ -18,6 +18,10 @@ * InputInterface is the interface implemented by all input classes. * * @author Fabien Potencier + * + * @method getRawArguments(bool $strip = false): array> Returns all the given arguments NOT merged with the default values. + * @method getRawOptions(): array|null> Returns all the given options NOT merged with the default values. + * @method unparse(): list Returns a stringified representation of the options passed to the command. */ interface InputInterface { diff --git a/src/Symfony/Component/Console/Tests/Input/ArgvInputTest.php b/src/Symfony/Component/Console/Tests/Input/ArgvInputTest.php index 0e76f9ee6db2a..5ae609de09aa8 100644 --- a/src/Symfony/Component/Console/Tests/Input/ArgvInputTest.php +++ b/src/Symfony/Component/Console/Tests/Input/ArgvInputTest.php @@ -594,4 +594,276 @@ public static function provideGetRawTokensTrueTests(): iterable yield [['app/console', '--no-ansi', 'foo:bar', 'foo:bar'], ['foo:bar']]; yield [['app/console', '--no-ansi', 'foo:bar', '--', 'argument'], ['--', 'argument']]; } + + /** + * @dataProvider unparseProvider + */ + public function testUnparse(?InputDefinition $inputDefinition, ArgvInput $input, ?array $parsedOptions, array $expected) + { + if (null !== $inputDefinition) { + $input->bind($inputDefinition); + } + + $actual = null === $parsedOptions ? $input->unparse() : $input->unparse($parsedOptions); + + self::assertSame($expected, $actual); + } + + public static function unparseProvider(): iterable + { + yield 'empty input and empty definition' => [ + new InputDefinition(), + new ArgvInput([]), + null, + [], + ]; + + yield 'empty input and definition with default values: ignore default values' => [ + new InputDefinition([ + new InputArgument( + 'argWithDefaultValue', + InputArgument::OPTIONAL, + 'Argument with a default value', + 'arg1DefaultValue', + ), + new InputOption( + 'optWithDefaultValue', + null, + InputOption::VALUE_REQUIRED, + 'Option with a default value', + 'opt1DefaultValue', + ), + ]), + new ArgvInput([]), + null, + [], + ]; + + $completeInputDefinition = new InputDefinition([ + new InputArgument( + 'requiredArgWithoutDefaultValue', + InputArgument::REQUIRED, + 'Argument without a default value', + ), + new InputArgument( + 'optionalArgWithDefaultValue', + InputArgument::OPTIONAL, + 'Argument with a default value', + 'argDefaultValue', + ), + new InputOption( + 'optWithoutDefaultValue', + null, + InputOption::VALUE_REQUIRED, + 'Option without a default value', + ), + new InputOption( + 'optWithDefaultValue', + null, + InputOption::VALUE_REQUIRED, + 'Option with a default value', + 'optDefaultValue', + ), + ]); + + yield 'arguments & options: returns all passed options but ignore default values' => [ + $completeInputDefinition, + new ArgvInput(['argValue', '--optWithoutDefaultValue=optValue']), + null, + ['--optWithoutDefaultValue=optValue'], + ]; + + yield 'arguments & options; explicitly pass the default values: the default values are returned' => [ + $completeInputDefinition, + new ArgvInput(['argValue', 'argDefaultValue', '--optWithoutDefaultValue=optValue', '--optWithDefaultValue=optDefaultValue']), + null, + [ + '--optWithoutDefaultValue=optValue', + '--optWithDefaultValue=optDefaultValue', + ], + ]; + + yield 'arguments & options; no input definition: nothing returned' => [ + null, + new ArgvInput(['argValue', 'argDefaultValue', '--optWithoutDefaultValue=optValue', '--optWithDefaultValue=optDefaultValue']), + null, + [], + ]; + + yield 'arguments & options; parsing an argument name instead of an option name: that option is ignored' => [ + $completeInputDefinition, + new ArgvInput(['argValue']), + ['requiredArgWithoutDefaultValue'], + [], + ]; + + yield 'arguments & options; non passed option: it is ignored' => [ + $completeInputDefinition, + new ArgvInput(['argValue']), + ['optWithDefaultValue'], + [], + ]; + + yield 'arguments & options; requesting a specific option' => [ + $completeInputDefinition, + new ArgvInput([ + '--optWithoutDefaultValue=optValue1', + '--optWithDefaultValue=optValue2', + ]), + ['optWithDefaultValue'], + ['--optWithDefaultValue=optValue2'], + ]; + + yield 'arguments & options; requesting no options' => [ + $completeInputDefinition, + new ArgvInput([ + '--optWithoutDefaultValue=optValue1', + '--optWithDefaultValue=optValue2', + ]), + [], + [], + ]; + + $createSingleOptionScenario = static fn ( + InputOption $option, + array $input, + array $expected, + ) => [ + new InputDefinition([$option]), + new ArgvInput(['appName', ...$input]), + null, + $expected, + ]; + + yield 'option without value' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_NONE, + ), + ['--opt'], + ['--opt'], + ); + + yield 'option without value by shortcut' => $createSingleOptionScenario( + new InputOption( + 'opt', + 'o', + InputOption::VALUE_NONE, + ), + ['-o'], + ['--opt'], + ); + + yield 'option with value required' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ['--opt=foo'], + ['--opt=foo'], + ); + + yield 'option with non string value (bool)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ['--opt=1'], + ['--opt=1'], + ); + + yield 'option with non string value (int)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ['--opt=20'], + ['--opt=20'], + ); + + yield 'option with non string value (float)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ['--opt=5.3'], + ['--opt=5.3'], + ); + + yield 'option with non string value (array of strings)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ), + ['--opt=v1', '--opt=v2', '--opt=v3 --opt=v4'], + ['--opt=v1', '--opt=v2', '--opt=v3 --opt=v4'], + ); + + yield 'negatable option (positive)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_NEGATABLE, + ), + ['--opt'], + ['--opt'], + ); + + yield 'negatable option (negative)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_NEGATABLE, + ), + ['--no-opt'], + ['--no-opt'], + ); + + $createEscapeOptionTokenScenario = static fn ( + string $optionValue, + ?string $expected, + ) => [ + new InputDefinition([ + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ]), + new ArgvInput(['appName', '--opt='.$optionValue]), + null, + ['--opt='.$expected], + ]; + + yield 'escape token; string token' => $createEscapeOptionTokenScenario( + 'foo', + 'foo', + ); + + yield 'escape token; escaped string token' => $createEscapeOptionTokenScenario( + '"foo"', + '"foo"', + ); + + yield 'escape token; escaped string token with both types of quotes' => $createEscapeOptionTokenScenario( + '"o_id in(\'20\')"', + '"o_id in(\'20\')"', + ); + + yield 'escape token; string token with spaces' => $createEscapeOptionTokenScenario( + 'a b c d', + 'a b c d', + ); + + yield 'escape token; string token with line return' => $createEscapeOptionTokenScenario( + "A\nB'C", + "A\nB'C", + ); + } } diff --git a/src/Symfony/Component/Console/Tests/Input/ArrayInputTest.php b/src/Symfony/Component/Console/Tests/Input/ArrayInputTest.php index 74d2c089fb7b8..baec22f44e616 100644 --- a/src/Symfony/Component/Console/Tests/Input/ArrayInputTest.php +++ b/src/Symfony/Component/Console/Tests/Input/ArrayInputTest.php @@ -170,4 +170,297 @@ public function testToString() $input = new ArrayInput(['array_arg' => ['val_1', 'val_2']]); $this->assertSame('val_1 val_2', (string) $input); } + + /** + * @dataProvider unparseProvider + */ + public function testUnparse( + ?InputDefinition $inputDefinition, + ArrayInput $input, + ?array $parsedOptions, + array $expected, + ) { + if (null !== $inputDefinition) { + $input->bind($inputDefinition); + } + + $actual = $input->unparse($parsedOptions); + + self::assertSame($expected, $actual); + } + + public static function unparseProvider(): iterable + { + yield 'empty input and empty definition' => [ + new InputDefinition(), + new ArrayInput([]), + null, + [], + ]; + + yield 'empty input and definition with default values: ignore default values' => [ + new InputDefinition([ + new InputArgument( + 'argWithDefaultValue', + InputArgument::OPTIONAL, + 'Argument with a default value', + 'arg1DefaultValue', + ), + new InputOption( + 'optWithDefaultValue', + null, + InputOption::VALUE_REQUIRED, + 'Option with a default value', + 'opt1DefaultValue', + ), + ]), + new ArrayInput([]), + null, + [], + ]; + + $completeInputDefinition = new InputDefinition([ + new InputArgument( + 'requiredArgWithoutDefaultValue', + InputArgument::REQUIRED, + 'Argument without a default value', + ), + new InputArgument( + 'optionalArgWithDefaultValue', + InputArgument::OPTIONAL, + 'Argument with a default value', + 'argDefaultValue', + ), + new InputOption( + 'optWithoutDefaultValue', + null, + InputOption::VALUE_REQUIRED, + 'Option without a default value', + ), + new InputOption( + 'optWithDefaultValue', + null, + InputOption::VALUE_REQUIRED, + 'Option with a default value', + 'optDefaultValue', + ), + ]); + + yield 'arguments & options: returns all passed options but ignore default values' => [ + $completeInputDefinition, + new ArrayInput([ + 'requiredArgWithoutDefaultValue' => 'argValue', + '--optWithoutDefaultValue' => 'optValue', + ]), + null, + ['--optWithoutDefaultValue=optValue'], + ]; + + yield 'arguments & options; explicitly pass the default values: the default values are returned' => [ + $completeInputDefinition, + new ArrayInput([ + 'requiredArgWithoutDefaultValue' => 'argValue', + 'optionalArgWithDefaultValue' => 'argDefaultValue', + '--optWithoutDefaultValue' => 'optValue', + '--optWithDefaultValue' => 'optDefaultValue', + ]), + null, + [ + '--optWithoutDefaultValue=optValue', + '--optWithDefaultValue=optDefaultValue', + ], + ]; + + yield 'arguments & options; no input definition: nothing returned' => [ + null, + new ArrayInput([ + 'requiredArgWithoutDefaultValue' => 'argValue', + 'optionalArgWithDefaultValue' => 'argDefaultValue', + '--optWithoutDefaultValue' => 'optValue', + '--optWithDefaultValue' => 'optDefaultValue', + ]), + null, + [], + ]; + + yield 'arguments & options; parsing an argument name instead of an option name: that option is ignored' => [ + $completeInputDefinition, + new ArrayInput(['requiredArgWithoutDefaultValue' => 'argValue']), + ['requiredArgWithoutDefaultValue'], + [], + ]; + + yield 'arguments & options; non passed option: it is ignored' => [ + $completeInputDefinition, + new ArrayInput(['requiredArgWithoutDefaultValue' => 'argValue']), + ['optWithDefaultValue'], + [], + ]; + + yield 'arguments & options; requesting a specific option' => [ + $completeInputDefinition, + new ArrayInput([ + '--optWithoutDefaultValue' => 'optValue1', + '--optWithDefaultValue' => 'optValue2', + ]), + ['optWithDefaultValue'], + ['--optWithDefaultValue=optValue2'], + ]; + + yield 'arguments & options; requesting no options' => [ + $completeInputDefinition, + new ArrayInput([ + '--optWithoutDefaultValue' => 'optValue1', + '--optWithDefaultValue' => 'optValue2', + ]), + [], + [], + ]; + + $createSingleOptionScenario = static fn ( + InputOption $option, + array $input, + array $expected, + ) => [ + new InputDefinition([$option]), + new ArrayInput($input), + null, + $expected, + ]; + + yield 'option without value' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_NONE, + ), + ['--opt' => null], + ['--opt'], + ); + + yield 'option without value by shortcut' => $createSingleOptionScenario( + new InputOption( + 'opt', + 'o', + InputOption::VALUE_NONE, + ), + ['-o' => null], + ['--opt'], + ); + + yield 'option with value required' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ['--opt' => 'foo'], + ['--opt=foo'], + ); + + yield 'option with non string value (bool)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ['--opt' => true], + ['--opt=1'], + ); + + yield 'option with non string value (int)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ['--opt' => 20], + ['--opt=20'], + ); + + yield 'option with non string value (float)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ['--opt' => 5.3], + ['--opt=5.3'], + ); + + yield 'option with non string value (array of strings)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY, + ), + ['--opt' => ['v1', 'v2', 'v3']], + ['--opt=v1', '--opt=v2', '--opt=v3'], + ); + + yield 'negatable option (positive)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_NEGATABLE, + ), + ['--opt' => null], + ['--opt'], + ); + + yield 'negatable option (negative)' => $createSingleOptionScenario( + new InputOption( + 'opt', + null, + InputOption::VALUE_NEGATABLE, + ), + ['--no-opt' => null], + ['--no-opt'], + ); + + $createEscapeOptionTokenScenario = static fn ( + string $optionValue, + ?string $expected, + ) => [ + new InputDefinition([ + new InputOption( + 'opt', + null, + InputOption::VALUE_REQUIRED, + ), + ]), + new ArrayInput([ + '--opt' => $optionValue, + ]), + null, + [ + '--opt='.$expected, + ], + ]; + + yield 'escape token; string token' => $createEscapeOptionTokenScenario( + 'foo', + 'foo', + ); + + yield 'escape token; escaped string token' => $createEscapeOptionTokenScenario( + '"foo"', + '"foo"', + ); + + yield 'escape token; escaped string token with both types of quotes' => $createEscapeOptionTokenScenario( + '"o_id in(\'20\')"', + '"o_id in(\'20\')"', + ); + + yield 'escape token; string token with spaces' => $createEscapeOptionTokenScenario( + 'a b c d', + 'a b c d', + ); + + yield 'escape token; string token with line return' => $createEscapeOptionTokenScenario( + "A\nB'C", + "A\nB'C", + ); + } } diff --git a/src/Symfony/Component/Console/Tests/Input/InputTest.php b/src/Symfony/Component/Console/Tests/Input/InputTest.php index 19a840da6f225..9022400a47501 100644 --- a/src/Symfony/Component/Console/Tests/Input/InputTest.php +++ b/src/Symfony/Component/Console/Tests/Input/InputTest.php @@ -45,6 +45,7 @@ public function testOptions() $input = new ArrayInput(['--name' => 'foo', '--bar' => null], new InputDefinition([new InputOption('name'), new InputOption('bar', '', InputOption::VALUE_OPTIONAL, '', 'default')])); $this->assertNull($input->getOption('bar'), '->getOption() returns null for options explicitly passed without value (or an empty value)'); $this->assertSame(['name' => 'foo', 'bar' => null], $input->getOptions(), '->getOptions() returns all option values'); + $this->assertSame(['name' => 'foo', 'bar' => null], $input->getRawOptions(), '->getRawOptions() returns all option values'); $input = new ArrayInput(['--name' => null], new InputDefinition([new InputOption('name', null, InputOption::VALUE_NEGATABLE)])); $this->assertTrue($input->hasOption('name')); @@ -89,10 +90,12 @@ public function testArguments() $input->setArgument('name', 'bar'); $this->assertSame('bar', $input->getArgument('name'), '->setArgument() sets the value for a given argument'); $this->assertSame(['name' => 'bar'], $input->getArguments(), '->getArguments() returns all argument values'); + $this->assertSame(['name' => 'bar'], $input->getRawArguments(), '->getRawArguments() returns all argument values'); $input = new ArrayInput(['name' => 'foo'], new InputDefinition([new InputArgument('name'), new InputArgument('bar', InputArgument::OPTIONAL, '', 'default')])); $this->assertSame('default', $input->getArgument('bar'), '->getArgument() returns the default value for optional arguments'); $this->assertSame(['name' => 'foo', 'bar' => 'default'], $input->getArguments(), '->getArguments() returns all argument values, even optional ones'); + $this->assertSame(['name' => 'foo'], $input->getRawArguments(), '->getRawArguments() returns all argument values, excluding optional ones'); } public function testSetInvalidArgument()