From edccb85880e347e589e8fa863942511fc0ea9542 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20L=C3=A9v=C3=AAque?= Date: Wed, 20 Nov 2024 14:27:46 +0100 Subject: [PATCH 01/45] [Console] Add support of millisecondes for `formatTime` --- Helper/Helper.php | 26 ++++++++++--------- Tests/Helper/HelperTest.php | 51 ++++++++++++++++++++----------------- 2 files changed, 41 insertions(+), 36 deletions(-) diff --git a/Helper/Helper.php b/Helper/Helper.php index 3981bbf3a..ddb2e9303 100644 --- a/Helper/Helper.php +++ b/Helper/Helper.php @@ -87,39 +87,41 @@ public static function substr(?string $string, int $from, ?int $length = null): public static function formatTime(int|float $secs, int $precision = 1): string { + $ms = (int) ($secs * 1000); $secs = (int) floor($secs); - if (0 === $secs) { - return '< 1 sec'; + if (0 === $ms) { + return '< 1 ms'; } static $timeFormats = [ - [1, '1 sec', 'secs'], - [60, '1 min', 'mins'], - [3600, '1 hr', 'hrs'], - [86400, '1 day', 'days'], + [1, 'ms'], + [1000, 's'], + [60000, 'min'], + [3600000, 'h'], + [86_400_000, 'd'], ]; $times = []; foreach ($timeFormats as $index => $format) { - $seconds = isset($timeFormats[$index + 1]) ? $secs % $timeFormats[$index + 1][0] : $secs; + $milliSeconds = isset($timeFormats[$index + 1]) ? $ms % $timeFormats[$index + 1][0] : $ms; if (isset($times[$index - $precision])) { unset($times[$index - $precision]); } - if (0 === $seconds) { + if (0 === $milliSeconds) { continue; } - $unitCount = ($seconds / $format[0]); - $times[$index] = 1 === $unitCount ? $format[1] : $unitCount.' '.$format[2]; + $unitCount = ($milliSeconds / $format[0]); + $times[$index] = $unitCount.' '.$format[1]; - if ($secs === $seconds) { + if ($ms === $milliSeconds) { break; } - $secs -= $seconds; + $ms -= $milliSeconds; } return implode(', ', array_reverse($times)); diff --git a/Tests/Helper/HelperTest.php b/Tests/Helper/HelperTest.php index 0a0c2fa48..009864454 100644 --- a/Tests/Helper/HelperTest.php +++ b/Tests/Helper/HelperTest.php @@ -20,31 +20,34 @@ class HelperTest extends TestCase public static function formatTimeProvider() { return [ - [0, '< 1 sec', 1], - [0.95, '< 1 sec', 1], - [1, '1 sec', 1], - [2, '2 secs', 2], - [59, '59 secs', 1], - [59.21, '59 secs', 1], + [0, '< 1 ms', 1], + [0.0004, '< 1 ms', 1], + [0.95, '950 ms', 1], + [1, '1 s', 1], + [2, '2 s', 2], + [59, '59 s', 1], + [59.21, '59 s', 1], + [59.21, '59 s, 210 ms', 5], [60, '1 min', 2], - [61, '1 min, 1 sec', 2], - [119, '1 min, 59 secs', 2], - [120, '2 mins', 2], - [121, '2 mins, 1 sec', 2], - [3599, '59 mins, 59 secs', 2], - [3600, '1 hr', 2], - [7199, '1 hr, 59 mins', 2], - [7200, '2 hrs', 2], - [7201, '2 hrs', 2], - [86399, '23 hrs, 59 mins', 2], - [86399, '23 hrs, 59 mins, 59 secs', 3], - [86400, '1 day', 2], - [86401, '1 day', 2], - [172799, '1 day, 23 hrs', 2], - [172799, '1 day, 23 hrs, 59 mins, 59 secs', 4], - [172800, '2 days', 2], - [172801, '2 days', 2], - [172801, '2 days, 1 sec', 4], + [61, '1 min, 1 s', 2], + [119, '1 min, 59 s', 2], + [120, '2 min', 2], + [121, '2 min, 1 s', 2], + [3599, '59 min, 59 s', 2], + [3600, '1 h', 2], + [7199, '1 h, 59 min', 2], + [7200, '2 h', 2], + [7201, '2 h', 2], + [86399, '23 h, 59 min', 2], + [86399, '23 h, 59 min, 59 s', 3], + [86400, '1 d', 2], + [86401, '1 d', 2], + [172799, '1 d, 23 h', 2], + [172799, '1 d, 23 h, 59 min, 59 s', 4], + [172799.123, '1 d, 23 h, 59 min, 59 s, 123 ms', 5], + [172800, '2 d', 2], + [172801, '2 d', 2], + [172801, '2 d, 1 s', 4], ]; } From 51accc898762e9a4a7212b2c28cd2a2e80f6f811 Mon Sep 17 00:00:00 2001 From: Alexandre Daubois Date: Tue, 10 Dec 2024 14:36:30 +0100 Subject: [PATCH 02/45] [Console] Fix time display in tests --- Tests/Helper/ProgressBarTest.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/Helper/ProgressBarTest.php b/Tests/Helper/ProgressBarTest.php index 3d1bfa48f..4e41ba69f 100644 --- a/Tests/Helper/ProgressBarTest.php +++ b/Tests/Helper/ProgressBarTest.php @@ -958,7 +958,7 @@ public function testAnsiColorsAndEmojis() $this->assertEquals( " \033[44;37m Starting the demo... fingers crossed \033[0m\n". ' 0/15 '.$progress.str_repeat($empty, 26)." 0%\n". - " \xf0\x9f\x8f\x81 < 1 sec \033[44;37m 0 B \033[0m", + " \xf0\x9f\x8f\x81 < 1 ms \033[44;37m 0 B \033[0m", stream_get_contents($output->getStream()) ); ftruncate($output->getStream(), 0); @@ -972,7 +972,7 @@ public function testAnsiColorsAndEmojis() $this->generateOutput( " \033[44;37m Looks good to me... \033[0m\n". ' 4/15 '.str_repeat($done, 7).$progress.str_repeat($empty, 19)." 26%\n". - " \xf0\x9f\x8f\x81 < 1 sec \033[41;37m 97 KiB \033[0m" + " \xf0\x9f\x8f\x81 < 1 ms \033[41;37m 97 KiB \033[0m" ), stream_get_contents($output->getStream()) ); @@ -987,7 +987,7 @@ public function testAnsiColorsAndEmojis() $this->generateOutput( " \033[44;37m Thanks, bye \033[0m\n". ' 15/15 '.str_repeat($done, 28)." 100%\n". - " \xf0\x9f\x8f\x81 < 1 sec \033[41;37m 195 KiB \033[0m" + " \xf0\x9f\x8f\x81 < 1 ms \033[41;37m 195 KiB \033[0m" ), stream_get_contents($output->getStream()) ); @@ -1022,7 +1022,7 @@ public function testSetFormatWithTimes() $bar->start(); rewind($output->getStream()); $this->assertEquals( - ' 0/15 [>---------------------------] 0% < 1 sec/< 1 sec/< 1 sec', + ' 0/15 [>---------------------------] 0% < 1 ms/< 1 ms/< 1 ms', stream_get_contents($output->getStream()) ); } @@ -1111,7 +1111,7 @@ public function testEmptyInputWithDebugFormat() rewind($output->getStream()); $this->assertEquals( - ' 0/0 [============================] 100% < 1 sec/< 1 sec', + ' 0/0 [============================] 100% < 1 ms/< 1 ms', stream_get_contents($output->getStream()) ); } From 3b41c171623d4e83db78eb7e4570133610611407 Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Wed, 11 Dec 2024 14:08:35 +0100 Subject: [PATCH 03/45] chore: PHP CS Fixer fixes --- Helper/QuestionHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 69afc2a67..8e1591ec1 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -55,7 +55,7 @@ public function ask(InputInterface $input, OutputInterface $output, Question $qu } $inputStream = $input instanceof StreamableInputInterface ? $input->getStream() : null; - $inputStream ??= STDIN; + $inputStream ??= \STDIN; try { if (!$question->getValidator()) { From 28d6a7c0d8389c1d1b8b6ade38c79a50754313b9 Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Fri, 13 Dec 2024 22:36:21 +0100 Subject: [PATCH 04/45] chore: PHP CS Fixer fixes --- Tests/Helper/TableTest.php | 44 +++++++++++++++++++------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/Tests/Helper/TableTest.php b/Tests/Helper/TableTest.php index 608d23c21..646c6baca 100644 --- a/Tests/Helper/TableTest.php +++ b/Tests/Helper/TableTest.php @@ -112,7 +112,7 @@ public static function renderProvider() | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+--------------------------+------------------+ -TABLE +TABLE, ], [ ['ISBN', 'Title', 'Author'], @@ -157,7 +157,7 @@ public static function renderProvider() │ 80-902734-1-6 │ And Then There Were None │ Agatha Christie │ └───────────────┴──────────────────────────┴──────────────────┘ -TABLE +TABLE, ], [ ['ISBN', 'Title', 'Author'], @@ -180,7 +180,7 @@ public static function renderProvider() ║ 80-902734-1-6 │ And Then There Were None │ Agatha Christie ║ ╚═══════════════╧══════════════════════════╧══════════════════╝ -TABLE +TABLE, ], [ ['ISBN', 'Title'], @@ -201,7 +201,7 @@ public static function renderProvider() | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+--------------------------+------------------+ -TABLE +TABLE, ], [ [], @@ -220,7 +220,7 @@ public static function renderProvider() | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+--------------------------+------------------+ -TABLE +TABLE, ], [ ['ISBN', 'Title', 'Author'], @@ -245,7 +245,7 @@ public static function renderProvider() | | | Tolkien | +---------------+----------------------------+-----------------+ -TABLE +TABLE, ], [ ['ISBN', 'Title'], @@ -256,7 +256,7 @@ public static function renderProvider() | ISBN | Title | +------+-------+ -TABLE +TABLE, ], [ [], @@ -279,7 +279,7 @@ public static function renderProvider() | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | +---------------+----------------------+-----------------+ -TABLE +TABLE, ], 'Cell text with tags not used for Output styling' => [ ['ISBN', 'Title', 'Author'], @@ -296,7 +296,7 @@ public static function renderProvider() | 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | +----------------------------------+----------------------+-----------------+ -TABLE +TABLE, ], 'Cell with colspan' => [ ['ISBN', 'Title', 'Author'], @@ -336,7 +336,7 @@ public static function renderProvider() | Cupìdĭtâte díctá âtquè pôrrò, tèmpórà exercitátìónèm mòdí ânìmí núllà nèmò vèl níhìl! | +-------------------------------+-------------------------------+-----------------------------+ -TABLE +TABLE, ], 'Cell after colspan contains new line break' => [ ['Foo', 'Bar', 'Baz'], @@ -355,7 +355,7 @@ public static function renderProvider() | bar | qux | +-----+-----+-----+ -TABLE +TABLE, ], 'Cell after colspan contains multiple new lines' => [ ['Foo', 'Bar', 'Baz'], @@ -375,7 +375,7 @@ public static function renderProvider() | | quux | +-----+-----+------+ -TABLE +TABLE, ], 'Cell with rowspan' => [ ['ISBN', 'Title', 'Author'], @@ -406,7 +406,7 @@ public static function renderProvider() | | Were None | | +---------------+---------------+-----------------+ -TABLE +TABLE, ], 'Cell with rowspan and colspan' => [ ['ISBN', 'Title', 'Author'], @@ -437,7 +437,7 @@ public static function renderProvider() | J. R. R | | +------------------+---------+-----------------+ -TABLE +TABLE, ], 'Cell with rowspan and colspan contains new line break' => [ ['ISBN', 'Title', 'Author'], @@ -480,7 +480,7 @@ public static function renderProvider() | 0-0 | | +-----------------+-------+-----------------+ -TABLE +TABLE, ], 'Cell with rowspan and colspan without using TableSeparator' => [ ['ISBN', 'Title', 'Author'], @@ -511,7 +511,7 @@ public static function renderProvider() | | 0-0 | +-----------------+-------+-----------------+ -TABLE +TABLE, ], 'Cell with rowspan and colspan with separator inside a rowspan' => [ ['ISBN', 'Author'], @@ -533,7 +533,7 @@ public static function renderProvider() | | Charles Dickens | +---------------+-----------------+ -TABLE +TABLE, ], 'Multiple header lines' => [ [ @@ -549,7 +549,7 @@ public static function renderProvider() | ISBN | Title | Author | +------+-------+--------+ -TABLE +TABLE, ], 'Row with multiple cells' => [ [], @@ -567,7 +567,7 @@ public static function renderProvider() | 1 | 2 | 3 | 4 | +---+--+--+---+--+---+--+---+--+ -TABLE +TABLE, ], 'Coslpan and table cells with comment style' => [ [ @@ -1305,7 +1305,7 @@ public static function renderSetTitle() | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+---------- footer --------+------------------+ -TABLE +TABLE, ], [ 'Books', @@ -1321,7 +1321,7 @@ public static function renderSetTitle() │ 80-902734-1-6 │ And Then There Were None │ Agatha Christie │ └───────────────┴───────── Page 1/2 ───────┴──────────────────┘ -TABLE +TABLE, ], [ 'Boooooooooooooooooooooooooooooooooooooooooooooooooooooooks', @@ -1337,7 +1337,7 @@ public static function renderSetTitle() | 80-902734-1-6 | And Then There Were None | Agatha Christie | +- Page 1/99999999999999999999999999999999999999999999999... -+ -TABLE +TABLE, ], ]; } From 100dd9ca58aaef09235be9d0da15fd89259f654e Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Tue, 31 Dec 2024 13:49:42 -0500 Subject: [PATCH 05/45] Add support for invokable commands and input attributes --- Attribute/Argument.php | 104 ++++++++++++++ Attribute/Option.php | 119 ++++++++++++++++ CHANGELOG.md | 6 + Command/Command.php | 21 ++- Command/InvokableCommand.php | 112 +++++++++++++++ DependencyInjection/AddConsoleCommandPass.php | 21 +-- Tests/Command/InvokableCommandTest.php | 131 ++++++++++++++++++ .../AddConsoleCommandPassTest.php | 24 +++- Tests/Helper/QuestionHelperTest.php | 2 +- Tests/Tester/ApplicationTesterTest.php | 8 +- Tests/Tester/CommandTesterTest.php | 18 +-- composer.json | 1 + 12 files changed, 538 insertions(+), 29 deletions(-) create mode 100644 Attribute/Argument.php create mode 100644 Attribute/Option.php create mode 100644 Command/InvokableCommand.php create mode 100644 Tests/Command/InvokableCommandTest.php diff --git a/Attribute/Argument.php b/Attribute/Argument.php new file mode 100644 index 000000000..c32d45c19 --- /dev/null +++ b/Attribute/Argument.php @@ -0,0 +1,104 @@ + + * + * 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\Completion\CompletionInput; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\InputArgument; +use Symfony\Component\Console\Input\InputInterface; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class Argument +{ + private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; + + private ?int $mode = null; + + /** + * Represents a console command definition. + * + * If unset, the `name` and `default` values will be inferred from the parameter definition. + * + * @param string|bool|int|float|array|null $default The default value (for InputArgument::OPTIONAL mode only) + * @param array|callable-string(CompletionInput):list $suggestedValues The values used for input completion + */ + public function __construct( + public string $name = '', + public string $description = '', + public string|bool|int|float|array|null $default = null, + public array|string $suggestedValues = [], + ) { + if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) { + throw new \TypeError(\sprintf('Argument 4 passed to "%s()" must be either an array or a callable-string.', __METHOD__)); + } + } + + /** + * @internal + */ + public static function tryFrom(\ReflectionParameter $parameter): ?self + { + /** @var self $self */ + if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) { + return null; + } + + $type = $parameter->getType(); + $name = $parameter->getName(); + + if (!$type instanceof \ReflectionNamedType) { + throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name)); + } + + $parameterTypeName = $type->getName(); + + if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) { + throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, implode('", "', self::ALLOWED_TYPES))); + } + + if (!$self->name) { + $self->name = $name; + } + + $self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; + if ('array' === $parameterTypeName) { + $self->mode |= InputArgument::IS_ARRAY; + } + + $self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + + if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { + $self->suggestedValues = [$instance, $self->suggestedValues[1]]; + } + + return $self; + } + + /** + * @internal + */ + public function toInputArgument(): InputArgument + { + $suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues; + + return new InputArgument($this->name, $this->mode, $this->description, $this->default, $suggestedValues); + } + + /** + * @internal + */ + public function resolveValue(InputInterface $input): mixed + { + return $input->hasArgument($this->name) ? $input->getArgument($this->name) : null; + } +} diff --git a/Attribute/Option.php b/Attribute/Option.php new file mode 100644 index 000000000..98d074b9d --- /dev/null +++ b/Attribute/Option.php @@ -0,0 +1,119 @@ + + * + * 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\Completion\CompletionInput; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +class Option +{ + private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; + + private ?int $mode = null; + private string $typeName = ''; + + /** + * Represents a console command --option definition. + * + * If unset, the `name` and `default` values will be inferred from the parameter definition. + * + * @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param scalar|array|null $default The default value (must be null for self::VALUE_NONE) + * @param array|callable-string(CompletionInput):list $suggestedValues The values used for input completion + */ + public function __construct( + public string $name = '', + public array|string|null $shortcut = null, + public string $description = '', + public string|bool|int|float|array|null $default = null, + public array|string $suggestedValues = [], + ) { + if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) { + throw new \TypeError(\sprintf('Argument 5 passed to "%s()" must be either an array or a callable-string.', __METHOD__)); + } + } + + /** + * @internal + */ + public static function tryFrom(\ReflectionParameter $parameter): ?self + { + /** @var self $self */ + if (null === $self = ($parameter->getAttributes(self::class, \ReflectionAttribute::IS_INSTANCEOF)[0] ?? null)?->newInstance()) { + return null; + } + + $type = $parameter->getType(); + $name = $parameter->getName(); + + if (!$type instanceof \ReflectionNamedType) { + throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command options.', $name)); + } + + $self->typeName = $type->getName(); + + if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) { + throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES))); + } + + if (!$self->name) { + $self->name = $name; + } + + if ('bool' === $self->typeName) { + $self->mode = InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE; + } else { + $self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED; + if ('array' === $self->typeName) { + $self->mode |= InputOption::VALUE_IS_ARRAY; + } + } + + if (InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $self->mode)) { + $self->default = null; + } else { + $self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + } + + if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { + $self->suggestedValues = [$instance, $self->suggestedValues[1]]; + } + + return $self; + } + + /** + * @internal + */ + public function toInputOption(): InputOption + { + $suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues; + + return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestedValues); + } + + /** + * @internal + */ + public function resolveValue(InputInterface $input): mixed + { + if ('bool' === $this->typeName) { + return $input->hasOption($this->name) && null !== $input->getOption($this->name) ? $input->getOption($this->name) : ($this->default ?? false); + } + + return $input->hasOption($this->name) ? $input->getOption($this->name) : null; + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c963568c..a8837b528 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,12 @@ CHANGELOG ========= +7.3 +--- + +* Add support for invokable commands +* Add `#[Argument]` and `#[Option]` attributes to define input arguments and options for invokable commands + 7.2 --- diff --git a/Command/Command.php b/Command/Command.php index 244a419f2..27d0651fa 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -49,7 +49,7 @@ class Command private string $description = ''; private ?InputDefinition $fullDefinition = null; private bool $ignoreValidationErrors = false; - private ?\Closure $code = null; + private ?InvokableCommand $code = null; private array $synopsis = []; private array $usages = []; private ?HelperSet $helperSet = null; @@ -164,6 +164,9 @@ public function isEnabled(): bool */ protected function configure() { + if (!$this->code && \is_callable($this)) { + $this->code = new InvokableCommand($this, $this(...)); + } } /** @@ -274,12 +277,10 @@ public function run(InputInterface $input, OutputInterface $output): int $input->validate(); if ($this->code) { - $statusCode = ($this->code)($input, $output); - } else { - $statusCode = $this->execute($input, $output); + return ($this->code)($input, $output); } - return is_numeric($statusCode) ? (int) $statusCode : 0; + return $this->execute($input, $output); } /** @@ -327,7 +328,7 @@ public function setCode(callable $code): static $code = $code(...); } - $this->code = $code; + $this->code = new InvokableCommand($this, $code); return $this; } @@ -395,7 +396,13 @@ public function getDefinition(): InputDefinition */ public function getNativeDefinition(): InputDefinition { - return $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class)); + $definition = $this->definition ?? throw new LogicException(\sprintf('Command class "%s" is not correctly initialized. You probably forgot to call the parent constructor.', static::class)); + + if ($this->code && !$definition->getArguments() && !$definition->getOptions()) { + $this->code->configure($definition); + } + + return $definition; } /** diff --git a/Command/InvokableCommand.php b/Command/InvokableCommand.php new file mode 100644 index 000000000..6c7136fda --- /dev/null +++ b/Command/InvokableCommand.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Command; + +use Symfony\Component\Console\Application; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Exception\RuntimeException; +use Symfony\Component\Console\Input\InputDefinition; +use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\OutputInterface; +use Symfony\Component\Console\Style\SymfonyStyle; + +/** + * Represents an invokable command. + * + * @author Yonel Ceruto + * + * @internal + */ +class InvokableCommand +{ + private readonly \ReflectionFunction $reflection; + + public function __construct( + private readonly Command $command, + private readonly \Closure $code, + ) { + $this->reflection = new \ReflectionFunction($code); + } + + /** + * Invokes a callable with parameters generated from the input interface. + */ + public function __invoke(InputInterface $input, OutputInterface $output): int + { + $statusCode = ($this->code)(...$this->getParameters($input, $output)); + + if (null !== $statusCode && !\is_int($statusCode)) { + // throw new LogicException(\sprintf('The command "%s" must return either void or an integer value in the "%s" method, but "%s" was returned.', $this->command->getName(), $this->reflection->getName(), get_debug_type($statusCode))); + trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in PHP 8.0.', $this->command->getName())); + + return 0; + } + + return $statusCode ?? 0; + } + + /** + * Configures the input definition from an invokable-defined function. + * + * Processes the parameters of the reflection function to extract and + * add arguments or options to the provided input definition. + */ + public function configure(InputDefinition $definition): void + { + foreach ($this->reflection->getParameters() as $parameter) { + if ($argument = Argument::tryFrom($parameter)) { + $definition->addArgument($argument->toInputArgument()); + } elseif ($option = Option::tryFrom($parameter)) { + $definition->addOption($option->toInputOption()); + } + } + } + + private function getParameters(InputInterface $input, OutputInterface $output): array + { + $parameters = []; + foreach ($this->reflection->getParameters() as $parameter) { + if ($argument = Argument::tryFrom($parameter)) { + $parameters[] = $argument->resolveValue($input); + + continue; + } + + if ($option = Option::tryFrom($parameter)) { + $parameters[] = $option->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())); + trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in PHP 8.0.', $parameter->getName())); + + continue; + } + + $parameters[] = match ($type->getName()) { + InputInterface::class => $input, + OutputInterface::class => $output, + SymfonyStyle::class => new SymfonyStyle($input, $output), + Application::class => $this->command->getApplication(), + default => throw new RuntimeException(\sprintf('Unsupported type "%s" for parameter "$%s".', $type->getName(), $parameter->getName())), + }; + } + + return $parameters ?: [$input, $output]; + } +} diff --git a/DependencyInjection/AddConsoleCommandPass.php b/DependencyInjection/AddConsoleCommandPass.php index f1521602a..78e355ad8 100644 --- a/DependencyInjection/AddConsoleCommandPass.php +++ b/DependencyInjection/AddConsoleCommandPass.php @@ -41,18 +41,21 @@ public function process(ContainerBuilder $container): void $definition->addTag('container.no_preload'); $class = $container->getParameterBag()->resolveValue($definition->getClass()); - if (isset($tags[0]['command'])) { - $aliases = $tags[0]['command']; - } else { - if (!$r = $container->getReflectionClass($class)) { - throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); - } - if (!$r->isSubclassOf(Command::class)) { - throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must be a subclass of "%s".', $id, 'console.command', Command::class)); + if (!$r = $container->getReflectionClass($class)) { + throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); + } + + if (!$r->isSubclassOf(Command::class)) { + if (!$r->hasMethod('__invoke')) { + throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must either be a subclass of "%s" or have an "__invoke()" method.', $id, 'console.command', Command::class)); } - $aliases = str_replace('%', '%%', $class::getDefaultName() ?? ''); + + $invokableRef = new Reference($id); + $definition = $container->register($id .= '.command', $class = Command::class) + ->addMethodCall('setCode', [$invokableRef]); } + $aliases = $tags[0]['command'] ?? str_replace('%', '%%', $class::getDefaultName() ?? ''); $aliases = explode('|', $aliases); $commandName = array_shift($aliases); diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php new file mode 100644 index 000000000..e6292c60d --- /dev/null +++ b/Tests/Command/InvokableCommandTest.php @@ -0,0 +1,131 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Command; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Attribute\Argument; +use Symfony\Component\Console\Attribute\Option; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Completion\CompletionInput; +use Symfony\Component\Console\Completion\CompletionSuggestions; +use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\LogicException; + +class InvokableCommandTest extends TestCase +{ + public function testCommandInputArgumentDefinition() + { + $command = new Command('foo'); + $command->setCode(function ( + #[Argument(name: 'first-name')] string $name, + #[Argument(default: '')] string $lastName, + #[Argument(description: 'Short argument description')] string $bio = '', + #[Argument(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], + ) {}); + + $nameInputArgument = $command->getDefinition()->getArgument('first-name'); + self::assertSame('first-name', $nameInputArgument->getName()); + self::assertTrue($nameInputArgument->isRequired()); + + $lastNameInputArgument = $command->getDefinition()->getArgument('lastName'); + self::assertSame('lastName', $lastNameInputArgument->getName()); + self::assertFalse($lastNameInputArgument->isRequired()); + self::assertSame('', $lastNameInputArgument->getDefault()); + + $bioInputArgument = $command->getDefinition()->getArgument('bio'); + self::assertSame('bio', $bioInputArgument->getName()); + self::assertFalse($bioInputArgument->isRequired()); + self::assertSame('Short argument description', $bioInputArgument->getDescription()); + self::assertSame('', $bioInputArgument->getDefault()); + + $rolesInputArgument = $command->getDefinition()->getArgument('roles'); + self::assertSame('roles', $rolesInputArgument->getName()); + self::assertFalse($rolesInputArgument->isRequired()); + self::assertTrue($rolesInputArgument->isArray()); + self::assertSame(['ROLE_USER'], $rolesInputArgument->getDefault()); + self::assertTrue($rolesInputArgument->hasCompletion()); + $rolesInputArgument->complete(new CompletionInput(), $suggestions = new CompletionSuggestions()); + self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions())); + } + + public function testCommandInputOptionDefinition() + { + $command = new Command('foo'); + $command->setCode(function ( + #[Option(name: 'idle')] int $timeout, + #[Option(default: 'USER_TYPE')] string $type, + #[Option(shortcut: 'v')] bool $verbose = false, + #[Option(description: 'User groups')] array $groups = [], + #[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], + ) {}); + + $timeoutInputOption = $command->getDefinition()->getOption('idle'); + self::assertSame('idle', $timeoutInputOption->getName()); + self::assertNull($timeoutInputOption->getShortcut()); + self::assertTrue($timeoutInputOption->isValueRequired()); + self::assertNull($timeoutInputOption->getDefault()); + + $typeInputOption = $command->getDefinition()->getOption('type'); + self::assertSame('type', $typeInputOption->getName()); + self::assertFalse($typeInputOption->isValueRequired()); + self::assertSame('USER_TYPE', $typeInputOption->getDefault()); + + $verboseInputOption = $command->getDefinition()->getOption('verbose'); + self::assertSame('verbose', $verboseInputOption->getName()); + self::assertSame('v', $verboseInputOption->getShortcut()); + self::assertFalse($verboseInputOption->isValueRequired()); + self::assertTrue($verboseInputOption->isNegatable()); + self::assertNull($verboseInputOption->getDefault()); + + $groupsInputOption = $command->getDefinition()->getOption('groups'); + self::assertSame('groups', $groupsInputOption->getName()); + self::assertTrue($groupsInputOption->isArray()); + self::assertSame('User groups', $groupsInputOption->getDescription()); + self::assertSame([], $groupsInputOption->getDefault()); + + $rolesInputOption = $command->getDefinition()->getOption('roles'); + self::assertSame('roles', $rolesInputOption->getName()); + self::assertFalse($rolesInputOption->isValueRequired()); + self::assertTrue($rolesInputOption->isArray()); + self::assertSame(['ROLE_USER'], $rolesInputOption->getDefault()); + self::assertTrue($rolesInputOption->hasCompletion()); + $rolesInputOption->complete(new CompletionInput(), $suggestions = new CompletionSuggestions()); + self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions())); + } + + public function testInvalidArgumentType() + { + $command = new Command('foo'); + $command->setCode(function (#[Argument] object $any) {}); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The type "object" of parameter "$any" is not supported as a command argument. Only "string", "bool", "int", "float", "array" types are allowed.'); + + $command->getDefinition(); + } + + public function testInvalidOptionType() + { + $command = new Command('foo'); + $command->setCode(function (#[Option] object $any) {}); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The type "object" of parameter "$any" is not supported as a command option. Only "string", "bool", "int", "float", "array" types are allowed.'); + + $command->getDefinition(); + } + + public function getSuggestedRoles(CompletionInput $input): array + { + return ['ROLE_ADMIN', 'ROLE_USER']; + } +} diff --git a/Tests/DependencyInjection/AddConsoleCommandPassTest.php b/Tests/DependencyInjection/AddConsoleCommandPassTest.php index 639e5091e..0df863720 100644 --- a/Tests/DependencyInjection/AddConsoleCommandPassTest.php +++ b/Tests/DependencyInjection/AddConsoleCommandPassTest.php @@ -206,7 +206,7 @@ public function testProcessThrowAnExceptionIfTheServiceIsNotASubclassOfCommand() $container->setDefinition('my-command', $definition); $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('The service "my-command" tagged "console.command" must be a subclass of "Symfony\Component\Console\Command\Command".'); + $this->expectExceptionMessage('The service "my-command" tagged "console.command" must either be a subclass of "Symfony\Component\Console\Command\Command" or have an "__invoke()" method'); $container->compile(); } @@ -303,6 +303,20 @@ public function testProcessOnChildDefinitionWithoutClass() $container->compile(); } + + public function testProcessInvokableCommand() + { + $container = new ContainerBuilder(); + $container->addCompilerPass(new AddConsoleCommandPass(), PassConfig::TYPE_BEFORE_REMOVING); + + $definition = new Definition(InvokableCommand::class); + $definition->addTag('console.command', ['command' => 'invokable', 'description' => 'Just testing']); + $container->setDefinition('invokable_command', $definition); + + $container->compile(); + + self::assertTrue($container->has('invokable_command.command')); + } } class MyCommand extends Command @@ -331,3 +345,11 @@ public function __construct() parent::__construct(); } } + +#[AsCommand(name: 'invokable', description: 'Just testing')] +class InvokableCommand +{ + public function __invoke(): void + { + } +} diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index 42da50273..dbbf66e02 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -777,7 +777,7 @@ public function testQuestionValidatorRepeatsThePrompt() $application = new Application(); $application->setAutoExit(false); $application->register('question') - ->setCode(function ($input, $output) use (&$tries) { + ->setCode(function (InputInterface $input, OutputInterface $output) use (&$tries) { $question = new Question('This is a promptable question'); $question->setValidator(function ($value) use (&$tries) { ++$tries; diff --git a/Tests/Tester/ApplicationTesterTest.php b/Tests/Tester/ApplicationTesterTest.php index f43775179..f990e94cc 100644 --- a/Tests/Tester/ApplicationTesterTest.php +++ b/Tests/Tester/ApplicationTesterTest.php @@ -14,7 +14,9 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\Console\Application; use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\Output; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Tester\ApplicationTester; @@ -29,7 +31,7 @@ protected function setUp(): void $this->application->setAutoExit(false); $this->application->register('foo') ->addArgument('foo') - ->setCode(function ($input, $output) { + ->setCode(function (OutputInterface $output) { $output->writeln('foo'); }) ; @@ -65,7 +67,7 @@ public function testSetInputs() { $application = new Application(); $application->setAutoExit(false); - $application->register('foo')->setCode(function ($input, $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { $helper = new QuestionHelper(); $helper->ask($input, $output, new Question('Q1')); $helper->ask($input, $output, new Question('Q2')); @@ -91,7 +93,7 @@ public function testErrorOutput() $application->setAutoExit(false); $application->register('foo') ->addArgument('foo') - ->setCode(function ($input, $output) { + ->setCode(function (OutputInterface $output) { $output->getErrorOutput()->write('foo'); }) ; diff --git a/Tests/Tester/CommandTesterTest.php b/Tests/Tester/CommandTesterTest.php index ce0a24b99..2e5329f84 100644 --- a/Tests/Tester/CommandTesterTest.php +++ b/Tests/Tester/CommandTesterTest.php @@ -16,7 +16,9 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\Output; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; @@ -32,7 +34,7 @@ protected function setUp(): void $this->command = new Command('foo'); $this->command->addArgument('command'); $this->command->addArgument('foo'); - $this->command->setCode(function ($input, $output) { $output->writeln('foo'); }); + $this->command->setCode(function (OutputInterface $output) { $output->writeln('foo'); }); $this->tester = new CommandTester($this->command); $this->tester->execute(['foo' => 'bar'], ['interactive' => false, 'decorated' => false, 'verbosity' => Output::VERBOSITY_VERBOSE]); @@ -92,7 +94,7 @@ public function testCommandFromApplication() $application->setAutoExit(false); $command = new Command('foo'); - $command->setCode(function ($input, $output) { $output->writeln('foo'); }); + $command->setCode(function (OutputInterface $output) { $output->writeln('foo'); }); $application->add($command); @@ -112,7 +114,7 @@ public function testCommandWithInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function ($input, $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { $helper = $command->getHelper('question'); $helper->ask($input, $output, new Question($questions[0])); $helper->ask($input, $output, new Question($questions[1])); @@ -137,7 +139,7 @@ public function testCommandWithDefaultInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function ($input, $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { $helper = $command->getHelper('question'); $helper->ask($input, $output, new Question($questions[0], 'Bobby')); $helper->ask($input, $output, new Question($questions[1], 'Fine')); @@ -162,7 +164,7 @@ public function testCommandWithWrongInputsNumber() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function ($input, $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { $helper = $command->getHelper('question'); $helper->ask($input, $output, new ChoiceQuestion('choice', ['a', 'b'])); $helper->ask($input, $output, new Question($questions[0])); @@ -189,7 +191,7 @@ public function testCommandWithQuestionsButNoInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function ($input, $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { $helper = $command->getHelper('question'); $helper->ask($input, $output, new ChoiceQuestion('choice', ['a', 'b'])); $helper->ask($input, $output, new Question($questions[0])); @@ -214,7 +216,7 @@ public function testSymfonyStyleCommandWithInputs() ]; $command = new Command('foo'); - $command->setCode(function ($input, $output) use ($questions) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions) { $io = new SymfonyStyle($input, $output); $io->ask($questions[0]); $io->ask($questions[1]); @@ -233,7 +235,7 @@ public function testErrorOutput() $command = new Command('foo'); $command->addArgument('command'); $command->addArgument('foo'); - $command->setCode(function ($input, $output) { + $command->setCode(function (OutputInterface $output) { $output->getErrorOutput()->write('foo'); }); diff --git a/composer.json b/composer.json index 0ed1bd9af..083036d5c 100644 --- a/composer.json +++ b/composer.json @@ -17,6 +17,7 @@ ], "require": { "php": ">=8.2", + "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", "symfony/string": "^6.4|^7.0" From 0a8b8a0bda8bef38c442d9e7a4dd327765d6de4c Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Thu, 9 Jan 2025 08:13:51 -0500 Subject: [PATCH 06/45] [Console] Invokable command deprecations --- CHANGELOG.md | 4 ++-- Command/Command.php | 2 +- Command/InvokableCommand.php | 19 +++++++++++++------ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a8837b528..c37f4f100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,8 @@ CHANGELOG 7.3 --- -* Add support for invokable commands -* Add `#[Argument]` and `#[Option]` attributes to define input arguments and options for invokable commands + * Add support for invokable commands and add `#[Argument]` and `#[Option]` attributes to define input arguments and options + * Deprecate not declaring the parameter type in callable commands defined through `setCode` method 7.2 --- diff --git a/Command/Command.php b/Command/Command.php index 27d0651fa..b9664371a 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -328,7 +328,7 @@ public function setCode(callable $code): static $code = $code(...); } - $this->code = new InvokableCommand($this, $code); + $this->code = new InvokableCommand($this, $code, triggerDeprecations: true); return $this; } diff --git a/Command/InvokableCommand.php b/Command/InvokableCommand.php index 6c7136fda..ccdbd0579 100644 --- a/Command/InvokableCommand.php +++ b/Command/InvokableCommand.php @@ -35,6 +35,7 @@ class InvokableCommand public function __construct( private readonly Command $command, private readonly \Closure $code, + private readonly bool $triggerDeprecations = false, ) { $this->reflection = new \ReflectionFunction($code); } @@ -47,10 +48,13 @@ public function __invoke(InputInterface $input, OutputInterface $output): int $statusCode = ($this->code)(...$this->getParameters($input, $output)); if (null !== $statusCode && !\is_int($statusCode)) { - // throw new LogicException(\sprintf('The command "%s" must return either void or an integer value in the "%s" method, but "%s" was returned.', $this->command->getName(), $this->reflection->getName(), get_debug_type($statusCode))); - trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in PHP 8.0.', $this->command->getName())); + if ($this->triggerDeprecations) { + trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in PHP 8.0.', $this->command->getName())); - return 0; + return 0; + } + + throw new LogicException(\sprintf('The command "%s" must return either void or an integer value in the "%s" method, but "%s" was returned.', $this->command->getName(), $this->reflection->getName(), get_debug_type($statusCode))); } return $statusCode ?? 0; @@ -92,10 +96,13 @@ private function getParameters(InputInterface $input, OutputInterface $output): $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())); - trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in PHP 8.0.', $parameter->getName())); + if ($this->triggerDeprecations) { + trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in PHP 8.0.', $parameter->getName())); - continue; + continue; + } + + 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()) { From 5afed8962999f2fd1db14e5c06194acb0cb3e95a Mon Sep 17 00:00:00 2001 From: Robin Chalas Date: Sun, 12 Jan 2025 16:30:14 +0100 Subject: [PATCH 07/45] [Console] Fix invokable command profiler representation --- Command/TraceableCommand.php | 13 +++++++++++++ DataCollector/CommandDataCollector.php | 6 +++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Command/TraceableCommand.php b/Command/TraceableCommand.php index 9ffb68da3..659798e65 100644 --- a/Command/TraceableCommand.php +++ b/Command/TraceableCommand.php @@ -45,6 +45,7 @@ final class TraceableCommand extends Command implements SignalableCommandInterfa /** @var array */ public array $interactiveInputs = []; public array $handledSignals = []; + public ?array $invokableCommandInfo = null; public function __construct( Command $command, @@ -171,6 +172,18 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti */ public function setCode(callable $code): static { + if ($code instanceof InvokableCommand) { + $r = new \ReflectionFunction(\Closure::bind(function () { + return $this->code; + }, $code, InvokableCommand::class)()); + + $this->invokableCommandInfo = [ + 'class' => $r->getClosureScopeClass()->name, + 'file' => $r->getFileName(), + 'line' => $r->getStartLine(), + ]; + } + $this->command->setCode($code); return parent::setCode(function (InputInterface $input, OutputInterface $output) use ($code): int { diff --git a/DataCollector/CommandDataCollector.php b/DataCollector/CommandDataCollector.php index 3cbe72b59..6dcac66bb 100644 --- a/DataCollector/CommandDataCollector.php +++ b/DataCollector/CommandDataCollector.php @@ -37,7 +37,7 @@ public function collect(Request $request, Response $response, ?\Throwable $excep $application = $command->getApplication(); $this->data = [ - 'command' => $this->cloneVar($command->command), + 'command' => $command->invokableCommandInfo ?? $this->cloneVar($command->command), 'exit_code' => $command->exitCode, 'interrupted_by_signal' => $command->interruptedBySignal, 'duration' => $command->duration, @@ -95,6 +95,10 @@ public function getName(): string */ public function getCommand(): array { + if (\is_array($this->data['command'])) { + return $this->data['command']; + } + $class = $this->data['command']->getType(); $r = new \ReflectionMethod($class, 'execute'); From 053f7c1d9e4ef12cac10396b0c53053884883150 Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Mon, 13 Jan 2025 10:35:32 -0500 Subject: [PATCH 08/45] [Console] Invokable command adjustments --- Attribute/Argument.php | 22 ++--- Attribute/Option.php | 52 ++++++---- Command/Command.php | 23 +---- Command/InvokableCommand.php | 32 +++++- Tests/Command/InvokableCommandTest.php | 132 +++++++++++++++++++++++-- 5 files changed, 197 insertions(+), 64 deletions(-) diff --git a/Attribute/Argument.php b/Attribute/Argument.php index c32d45c19..099d49676 100644 --- a/Attribute/Argument.php +++ b/Attribute/Argument.php @@ -22,25 +22,23 @@ class Argument { private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; + private string|bool|int|float|array|null $default = null; + private array|\Closure $suggestedValues; private ?int $mode = null; /** * Represents a console command definition. * - * If unset, the `name` and `default` values will be inferred from the parameter definition. + * If unset, the `name` value will be inferred from the parameter definition. * - * @param string|bool|int|float|array|null $default The default value (for InputArgument::OPTIONAL mode only) - * @param array|callable-string(CompletionInput):list $suggestedValues The values used for input completion + * @param array|callable(CompletionInput):list $suggestedValues The values used for input completion */ public function __construct( public string $name = '', public string $description = '', - public string|bool|int|float|array|null $default = null, - public array|string $suggestedValues = [], + array|callable $suggestedValues = [], ) { - if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) { - throw new \TypeError(\sprintf('Argument 4 passed to "%s()" must be either an array or a callable-string.', __METHOD__)); - } + $this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues; } /** @@ -70,13 +68,13 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self $self->name = $name; } - $self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; + $self->default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; + + $self->mode = $parameter->isDefaultValueAvailable() || $parameter->allowsNull() ? InputArgument::OPTIONAL : InputArgument::REQUIRED; if ('array' === $parameterTypeName) { $self->mode |= InputArgument::IS_ARRAY; } - $self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; - if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { $self->suggestedValues = [$instance, $self->suggestedValues[1]]; } @@ -99,6 +97,6 @@ public function toInputArgument(): InputArgument */ public function resolveValue(InputInterface $input): mixed { - return $input->hasArgument($this->name) ? $input->getArgument($this->name) : null; + return $input->getArgument($this->name); } } diff --git a/Attribute/Option.php b/Attribute/Option.php index 98d074b9d..02002a5ad 100644 --- a/Attribute/Option.php +++ b/Attribute/Option.php @@ -22,28 +22,27 @@ class Option { private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; + private string|bool|int|float|array|null $default = null; + private array|\Closure $suggestedValues; private ?int $mode = null; private string $typeName = ''; + private bool $allowNull = false; /** * Represents a console command --option definition. * - * If unset, the `name` and `default` values will be inferred from the parameter definition. + * If unset, the `name` value will be inferred from the parameter definition. * - * @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts - * @param scalar|array|null $default The default value (must be null for self::VALUE_NONE) - * @param array|callable-string(CompletionInput):list $suggestedValues The values used for input completion + * @param array|string|null $shortcut The shortcuts, can be null, a string of shortcuts delimited by | or an array of shortcuts + * @param array|callable(CompletionInput):list $suggestedValues The values used for input completion */ public function __construct( public string $name = '', public array|string|null $shortcut = null, public string $description = '', - public string|bool|int|float|array|null $default = null, - public array|string $suggestedValues = [], + array|callable $suggestedValues = [], ) { - if (\is_string($suggestedValues) && !\is_callable($suggestedValues)) { - throw new \TypeError(\sprintf('Argument 5 passed to "%s()" must be either an array or a callable-string.', __METHOD__)); - } + $this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues; } /** @@ -69,25 +68,29 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES))); } + if (!$parameter->isDefaultValueAvailable()) { + throw new LogicException(\sprintf('The option parameter "$%s" must declare a default value.', $name)); + } + if (!$self->name) { $self->name = $name; } + $self->default = $parameter->getDefaultValue(); + $self->allowNull = $parameter->allowsNull(); + if ('bool' === $self->typeName) { - $self->mode = InputOption::VALUE_NONE | InputOption::VALUE_NEGATABLE; + $self->mode = InputOption::VALUE_NONE; + if (false !== $self->default) { + $self->mode |= InputOption::VALUE_NEGATABLE; + } } else { - $self->mode = null !== $self->default || $parameter->isDefaultValueAvailable() ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED; + $self->mode = $self->allowNull ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED; if ('array' === $self->typeName) { $self->mode |= InputOption::VALUE_IS_ARRAY; } } - if (InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $self->mode)) { - $self->default = null; - } else { - $self->default ??= $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; - } - if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { $self->suggestedValues = [$instance, $self->suggestedValues[1]]; } @@ -100,9 +103,10 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self */ public function toInputOption(): InputOption { + $default = InputOption::VALUE_NONE === (InputOption::VALUE_NONE & $this->mode) ? null : $this->default; $suggestedValues = \is_callable($this->suggestedValues) ? ($this->suggestedValues)(...) : $this->suggestedValues; - return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $this->default, $suggestedValues); + return new InputOption($this->name, $this->shortcut, $this->mode, $this->description, $default, $suggestedValues); } /** @@ -110,10 +114,16 @@ public function toInputOption(): InputOption */ public function resolveValue(InputInterface $input): mixed { - if ('bool' === $this->typeName) { - return $input->hasOption($this->name) && null !== $input->getOption($this->name) ? $input->getOption($this->name) : ($this->default ?? false); + $value = $input->getOption($this->name); + + if ('bool' !== $this->typeName) { + return $value; + } + + if ($this->allowNull && null === $value) { + return null; } - return $input->hasOption($this->name) ? $input->getOption($this->name) : null; + return $value ?? $this->default; } } diff --git a/Command/Command.php b/Command/Command.php index b9664371a..6c85825d1 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -100,6 +100,10 @@ public function __construct(?string $name = null) $this->setDescription(static::getDefaultDescription() ?? ''); } + if (\is_callable($this)) { + $this->code = new InvokableCommand($this, $this(...)); + } + $this->configure(); } @@ -164,9 +168,6 @@ public function isEnabled(): bool */ protected function configure() { - if (!$this->code && \is_callable($this)) { - $this->code = new InvokableCommand($this, $this(...)); - } } /** @@ -312,22 +313,6 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti */ public function setCode(callable $code): static { - if ($code instanceof \Closure) { - $r = new \ReflectionFunction($code); - if (null === $r->getClosureThis()) { - set_error_handler(static function () {}); - try { - if ($c = \Closure::bind($code, $this)) { - $code = $c; - } - } finally { - restore_error_handler(); - } - } - } else { - $code = $code(...); - } - $this->code = new InvokableCommand($this, $code, triggerDeprecations: true); return $this; diff --git a/Command/InvokableCommand.php b/Command/InvokableCommand.php index ccdbd0579..2b3c41501 100644 --- a/Command/InvokableCommand.php +++ b/Command/InvokableCommand.php @@ -30,14 +30,16 @@ */ class InvokableCommand { + private readonly \Closure $code; private readonly \ReflectionFunction $reflection; public function __construct( private readonly Command $command, - private readonly \Closure $code, + callable $code, private readonly bool $triggerDeprecations = false, ) { - $this->reflection = new \ReflectionFunction($code); + $this->code = $this->getClosure($code); + $this->reflection = new \ReflectionFunction($this->code); } /** @@ -49,7 +51,7 @@ public function __invoke(InputInterface $input, OutputInterface $output): int if (null !== $statusCode && !\is_int($statusCode)) { if ($this->triggerDeprecations) { - trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in PHP 8.0.', $this->command->getName())); + trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in Symfony 8.0.', $this->command->getName())); return 0; } @@ -77,6 +79,28 @@ public function configure(InputDefinition $definition): void } } + private function getClosure(callable $code): \Closure + { + if (!$code instanceof \Closure) { + return $code(...); + } + + if (null !== (new \ReflectionFunction($code))->getClosureThis()) { + return $code; + } + + set_error_handler(static function () {}); + try { + if ($c = \Closure::bind($code, $this->command)) { + $code = $c; + } + } finally { + restore_error_handler(); + } + + return $code; + } + private function getParameters(InputInterface $input, OutputInterface $output): array { $parameters = []; @@ -97,7 +121,7 @@ private function getParameters(InputInterface $input, OutputInterface $output): if (!$type instanceof \ReflectionNamedType) { if ($this->triggerDeprecations) { - trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in PHP 8.0.', $parameter->getName())); + trigger_deprecation('symfony/console', '7.3', \sprintf('Omitting the type declaration for the parameter "$%s" is deprecated and will throw an exception in Symfony 8.0.', $parameter->getName())); continue; } diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index e6292c60d..3633c8659 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -18,7 +18,10 @@ use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; use Symfony\Component\Console\Completion\Suggestion; +use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Exception\LogicException; +use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Output\NullOutput; class InvokableCommandTest extends TestCase { @@ -27,7 +30,8 @@ public function testCommandInputArgumentDefinition() $command = new Command('foo'); $command->setCode(function ( #[Argument(name: 'first-name')] string $name, - #[Argument(default: '')] string $lastName, + #[Argument] ?string $firstName, + #[Argument] string $lastName = '', #[Argument(description: 'Short argument description')] string $bio = '', #[Argument(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], ) {}); @@ -36,6 +40,11 @@ public function testCommandInputArgumentDefinition() self::assertSame('first-name', $nameInputArgument->getName()); self::assertTrue($nameInputArgument->isRequired()); + $lastNameInputArgument = $command->getDefinition()->getArgument('firstName'); + self::assertSame('firstName', $lastNameInputArgument->getName()); + self::assertFalse($lastNameInputArgument->isRequired()); + self::assertNull($lastNameInputArgument->getDefault()); + $lastNameInputArgument = $command->getDefinition()->getArgument('lastName'); self::assertSame('lastName', $lastNameInputArgument->getName()); self::assertFalse($lastNameInputArgument->isRequired()); @@ -61,8 +70,8 @@ public function testCommandInputOptionDefinition() { $command = new Command('foo'); $command->setCode(function ( - #[Option(name: 'idle')] int $timeout, - #[Option(default: 'USER_TYPE')] string $type, + #[Option(name: 'idle')] ?int $timeout = null, + #[Option] string $type = 'USER_TYPE', #[Option(shortcut: 'v')] bool $verbose = false, #[Option(description: 'User groups')] array $groups = [], #[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], @@ -71,30 +80,35 @@ public function testCommandInputOptionDefinition() $timeoutInputOption = $command->getDefinition()->getOption('idle'); self::assertSame('idle', $timeoutInputOption->getName()); self::assertNull($timeoutInputOption->getShortcut()); - self::assertTrue($timeoutInputOption->isValueRequired()); + self::assertTrue($timeoutInputOption->isValueOptional()); + self::assertFalse($timeoutInputOption->isNegatable()); self::assertNull($timeoutInputOption->getDefault()); $typeInputOption = $command->getDefinition()->getOption('type'); self::assertSame('type', $typeInputOption->getName()); - self::assertFalse($typeInputOption->isValueRequired()); + self::assertTrue($typeInputOption->isValueRequired()); + self::assertFalse($typeInputOption->isNegatable()); self::assertSame('USER_TYPE', $typeInputOption->getDefault()); $verboseInputOption = $command->getDefinition()->getOption('verbose'); self::assertSame('verbose', $verboseInputOption->getName()); self::assertSame('v', $verboseInputOption->getShortcut()); self::assertFalse($verboseInputOption->isValueRequired()); - self::assertTrue($verboseInputOption->isNegatable()); - self::assertNull($verboseInputOption->getDefault()); + self::assertFalse($verboseInputOption->isValueOptional()); + self::assertFalse($verboseInputOption->isNegatable()); + self::assertFalse($verboseInputOption->getDefault()); $groupsInputOption = $command->getDefinition()->getOption('groups'); self::assertSame('groups', $groupsInputOption->getName()); self::assertTrue($groupsInputOption->isArray()); self::assertSame('User groups', $groupsInputOption->getDescription()); + self::assertFalse($groupsInputOption->isNegatable()); self::assertSame([], $groupsInputOption->getDefault()); $rolesInputOption = $command->getDefinition()->getOption('roles'); self::assertSame('roles', $rolesInputOption->getName()); - self::assertFalse($rolesInputOption->isValueRequired()); + self::assertTrue($rolesInputOption->isValueRequired()); + self::assertFalse($rolesInputOption->isNegatable()); self::assertTrue($rolesInputOption->isArray()); self::assertSame(['ROLE_USER'], $rolesInputOption->getDefault()); self::assertTrue($rolesInputOption->hasCompletion()); @@ -124,6 +138,108 @@ public function testInvalidOptionType() $command->getDefinition(); } + /** + * @dataProvider provideInputArguments + */ + public function testInputArguments(array $parameters, array $expected) + { + $command = new Command('foo'); + $command->setCode(function ( + #[Argument] string $a, + #[Argument] ?string $b, + #[Argument] string $c = '', + #[Argument] array $d = [], + ) use ($expected) { + $this->assertSame($expected[0], $a); + $this->assertSame($expected[1], $b); + $this->assertSame($expected[2], $c); + $this->assertSame($expected[3], $d); + }); + + $command->run(new ArrayInput($parameters), new NullOutput()); + } + + public static function provideInputArguments(): \Generator + { + yield 'required & defaults' => [['a' => 'x'], ['x', null, '', []]]; + yield 'required & with-value' => [['a' => 'x', 'b' => 'y', 'c' => 'z', 'd' => ['d']], ['x', 'y', 'z', ['d']]]; + yield 'required & without-value' => [['a' => 'x', 'b' => null, 'c' => null, 'd' => null], ['x', null, '', []]]; + } + + /** + * @dataProvider provideBinaryInputOptions + */ + public function testBinaryInputOptions(array $parameters, array $expected) + { + $command = new Command('foo'); + $command->setCode(function ( + #[Option] bool $a = true, + #[Option] bool $b = false, + #[Option] ?bool $c = null, + ) use ($expected) { + $this->assertSame($expected[0], $a); + $this->assertSame($expected[1], $b); + $this->assertSame($expected[2], $c); + }); + + $command->run(new ArrayInput($parameters), new NullOutput()); + } + + public static function provideBinaryInputOptions(): \Generator + { + yield 'defaults' => [[], [true, false, null]]; + yield 'positive' => [['--a' => null, '--b' => null, '--c' => null], [true, true, true]]; + yield 'negative' => [['--no-a' => null, '--no-c' => null], [false, false, false]]; + } + + /** + * @dataProvider provideNonBinaryInputOptions + */ + public function testNonBinaryInputOptions(array $parameters, array $expected) + { + $command = new Command('foo'); + $command->setCode(function ( + #[Option] ?string $a = null, + #[Option] ?string $b = 'b', + #[Option] ?array $c = [], + ) use ($expected) { + $this->assertSame($expected[0], $a); + $this->assertSame($expected[1], $b); + $this->assertSame($expected[2], $c); + }); + + $command->run(new ArrayInput($parameters), new NullOutput()); + } + + public static function provideNonBinaryInputOptions(): \Generator + { + yield 'defaults' => [[], [null, 'b', []]]; + yield 'with-value' => [['--a' => 'x', '--b' => 'y', '--c' => ['z']], ['x', 'y', ['z']]]; + yield 'without-value' => [['--a' => null, '--b' => null, '--c' => null], [null, null, null]]; + } + + public function testInvalidOptionDefinition() + { + $command = new Command('foo'); + $command->setCode(function (#[Option] string $a) {}); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The option parameter "$a" must declare a default value.'); + + $command->getDefinition(); + } + + public function testInvalidRequiredValueOptionEvenWithDefault() + { + $command = new Command('foo'); + $command->setCode(function (#[Option] string $a = 'a') {}); + + $this->expectException(InvalidOptionException::class); + $this->expectExceptionMessage('The "--a" option requires a value.'); + + $command->run(new ArrayInput(['--a' => null]), new NullOutput()); + } + public function getSuggestedRoles(CompletionInput $input): array { return ['ROLE_ADMIN', 'ROLE_USER']; From 164fa2c4ee382c7de84f0b7c66c9d07087bedda1 Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Fri, 10 Jan 2025 15:50:54 -0500 Subject: [PATCH 09/45] [Console] Add broader support for command "help" definition --- Attribute/AsCommand.php | 2 ++ CHANGELOG.md | 1 + Command/Command.php | 4 ++++ DependencyInjection/AddConsoleCommandPass.php | 16 ++++++++-------- Tests/Command/CommandTest.php | 3 ++- .../AddConsoleCommandPassTest.php | 14 +++++++++++--- 6 files changed, 28 insertions(+), 12 deletions(-) diff --git a/Attribute/AsCommand.php b/Attribute/AsCommand.php index 6066d7c53..2147e7151 100644 --- a/Attribute/AsCommand.php +++ b/Attribute/AsCommand.php @@ -22,12 +22,14 @@ class AsCommand * @param string|null $description The description of the command, displayed with the help page * @param string[] $aliases The list of aliases of the command. The command will be executed when using one of them (i.e. "cache:clean") * @param bool $hidden If true, the command won't be shown when listing all the available commands, but it can still be run as any other command + * @param string|null $help The help content of the command, displayed with the help page */ public function __construct( public string $name, public ?string $description = null, array $aliases = [], bool $hidden = false, + public ?string $help = null, ) { if (!$hidden && !$aliases) { return; diff --git a/CHANGELOG.md b/CHANGELOG.md index c37f4f100..77b109b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ CHANGELOG * Add support for invokable commands and add `#[Argument]` and `#[Option]` attributes to define input arguments and options * Deprecate not declaring the parameter type in callable commands defined through `setCode` method + * Add support for help definition via `AsCommand` attribute 7.2 --- diff --git a/Command/Command.php b/Command/Command.php index 6c85825d1..fb410d7f8 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -100,6 +100,10 @@ public function __construct(?string $name = null) $this->setDescription(static::getDefaultDescription() ?? ''); } + if ('' === $this->help && $attributes = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) { + $this->setHelp($attributes[0]->newInstance()->help ?? ''); + } + if (\is_callable($this)) { $this->code = new InvokableCommand($this, $this(...)); } diff --git a/DependencyInjection/AddConsoleCommandPass.php b/DependencyInjection/AddConsoleCommandPass.php index 78e355ad8..248ad3276 100644 --- a/DependencyInjection/AddConsoleCommandPass.php +++ b/DependencyInjection/AddConsoleCommandPass.php @@ -53,6 +53,8 @@ public function process(ContainerBuilder $container): void $invokableRef = new Reference($id); $definition = $container->register($id .= '.command', $class = Command::class) ->addMethodCall('setCode', [$invokableRef]); + } else { + $invokableRef = null; } $aliases = $tags[0]['command'] ?? str_replace('%', '%%', $class::getDefaultName() ?? ''); @@ -75,6 +77,7 @@ public function process(ContainerBuilder $container): void } $description = $tags[0]['description'] ?? null; + $help = $tags[0]['help'] ?? null; unset($tags[0]); $lazyCommandMap[$commandName] = $id; @@ -91,6 +94,7 @@ public function process(ContainerBuilder $container): void } $description ??= $tag['description'] ?? null; + $help ??= $tag['help'] ?? null; } $definition->addMethodCall('setName', [$commandName]); @@ -103,16 +107,12 @@ public function process(ContainerBuilder $container): void $definition->addMethodCall('setHidden', [true]); } - if (!$description) { - if (!$r = $container->getReflectionClass($class)) { - throw new InvalidArgumentException(\sprintf('Class "%s" used for service "%s" cannot be found.', $class, $id)); - } - if (!$r->isSubclassOf(Command::class)) { - throw new InvalidArgumentException(\sprintf('The service "%s" tagged "%s" must be a subclass of "%s".', $id, 'console.command', Command::class)); - } - $description = str_replace('%', '%%', $class::getDefaultDescription() ?? ''); + if ($help && $invokableRef) { + $definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]); } + $description ??= str_replace('%', '%%', $class::getDefaultDescription() ?? ''); + if ($description) { $definition->addMethodCall('setDescription', [$description]); diff --git a/Tests/Command/CommandTest.php b/Tests/Command/CommandTest.php index 199c0c309..ef6f04c2d 100644 --- a/Tests/Command/CommandTest.php +++ b/Tests/Command/CommandTest.php @@ -434,6 +434,7 @@ public function testCommandAttribute() $this->assertSame('foo', $command->getName()); $this->assertSame('desc', $command->getDescription()); + $this->assertSame('help', $command->getHelp()); $this->assertTrue($command->isHidden()); $this->assertSame(['f'], $command->getAliases()); } @@ -473,7 +474,7 @@ function createClosure() }; } -#[AsCommand(name: 'foo', description: 'desc', hidden: true, aliases: ['f'])] +#[AsCommand(name: 'foo', description: 'desc', hidden: true, aliases: ['f'], help: 'help')] class Php8Command extends Command { } diff --git a/Tests/DependencyInjection/AddConsoleCommandPassTest.php b/Tests/DependencyInjection/AddConsoleCommandPassTest.php index 0df863720..8a0c1e6b2 100644 --- a/Tests/DependencyInjection/AddConsoleCommandPassTest.php +++ b/Tests/DependencyInjection/AddConsoleCommandPassTest.php @@ -176,6 +176,7 @@ public function testEscapesDefaultFromPhp() $this->assertSame('%cmd%', $command->getName()); $this->assertSame(['%cmdalias%'], $command->getAliases()); $this->assertSame('Creates a 80% discount', $command->getDescription()); + $this->assertSame('The %command.name% help content.', $command->getHelp()); } public function testProcessThrowAnExceptionIfTheServiceIsAbstract() @@ -310,12 +311,19 @@ public function testProcessInvokableCommand() $container->addCompilerPass(new AddConsoleCommandPass(), PassConfig::TYPE_BEFORE_REMOVING); $definition = new Definition(InvokableCommand::class); - $definition->addTag('console.command', ['command' => 'invokable', 'description' => 'Just testing']); + $definition->addTag('console.command', [ + 'command' => 'invokable', + 'description' => 'The command description', + 'help' => 'The %command.name% command help content.', + ]); $container->setDefinition('invokable_command', $definition); $container->compile(); + $command = $container->get('console.command_loader')->get('invokable'); self::assertTrue($container->has('invokable_command.command')); + self::assertSame('The command description', $command->getDescription()); + self::assertSame('The %command.name% command help content.', $command->getHelp()); } } @@ -328,7 +336,7 @@ class NamedCommand extends Command { } -#[AsCommand(name: '%cmd%|%cmdalias%', description: 'Creates a 80% discount')] +#[AsCommand(name: '%cmd%|%cmdalias%', description: 'Creates a 80% discount', help: 'The %command.name% help content.')] class EscapedDefaultsFromPhpCommand extends Command { } @@ -346,7 +354,7 @@ public function __construct() } } -#[AsCommand(name: 'invokable', description: 'Just testing')] +#[AsCommand(name: 'invokable', description: 'Just testing', help: 'The %command.name% help content.')] class InvokableCommand { public function __invoke(): void From 12b71ff91b978d6db0215b49cc3eedb287585c2e Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Mon, 20 Jan 2025 18:52:35 -0500 Subject: [PATCH 10/45] Deprecating command getDefault* methods --- CHANGELOG.md | 1 + Command/Command.php | 38 ++++++++++-- DependencyInjection/AddConsoleCommandPass.php | 26 +++++++- Tests/ApplicationTest.php | 2 +- Tests/Command/CommandTest.php | 61 ++++++++++++++++--- 5 files changed, 112 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b109b81..fc2b64bf1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ CHANGELOG * Add support for invokable commands and add `#[Argument]` and `#[Option]` attributes to define input arguments and options * Deprecate not declaring the parameter type in callable commands defined through `setCode` method * Add support for help definition via `AsCommand` attribute + * Deprecate methods `Command::getDefaultName()` and `Command::getDefaultDescription()` in favor of the `#[AsCommand]` attribute 7.2 --- diff --git a/Command/Command.php b/Command/Command.php index fb410d7f8..5e30eb5fa 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -54,8 +54,13 @@ class Command private array $usages = []; private ?HelperSet $helperSet = null; + /** + * @deprecated since Symfony 7.3, use the #[AsCommand] attribute instead + */ public static function getDefaultName(): ?string { + trigger_deprecation('symfony/console', '7.3', 'Method "%s()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', __METHOD__); + if ($attribute = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) { return $attribute[0]->newInstance()->name; } @@ -63,8 +68,13 @@ public static function getDefaultName(): ?string return null; } + /** + * @deprecated since Symfony 7.3, use the #[AsCommand] attribute instead + */ public static function getDefaultDescription(): ?string { + trigger_deprecation('symfony/console', '7.3', 'Method "%s()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', __METHOD__); + if ($attribute = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) { return $attribute[0]->newInstance()->description; } @@ -81,7 +91,19 @@ public function __construct(?string $name = null) { $this->definition = new InputDefinition(); - if (null === $name && null !== $name = static::getDefaultName()) { + $attribute = ((new \ReflectionClass(static::class))->getAttributes(AsCommand::class)[0] ?? null)?->newInstance(); + + if (null === $name) { + if (self::class !== (new \ReflectionMethod($this, 'getDefaultName'))->class) { + trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultName()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', static::class); + + $defaultName = static::getDefaultName(); + } else { + $defaultName = $attribute?->name; + } + } + + if (null === $name && null !== $name = $defaultName) { $aliases = explode('|', $name); if ('' === $name = array_shift($aliases)) { @@ -97,11 +119,19 @@ public function __construct(?string $name = null) } if ('' === $this->description) { - $this->setDescription(static::getDefaultDescription() ?? ''); + if (self::class !== (new \ReflectionMethod($this, 'getDefaultDescription'))->class) { + trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultDescription()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', static::class); + + $defaultDescription = static::getDefaultDescription(); + } else { + $defaultDescription = $attribute?->description; + } + + $this->setDescription($defaultDescription ?? ''); } - if ('' === $this->help && $attributes = (new \ReflectionClass(static::class))->getAttributes(AsCommand::class)) { - $this->setHelp($attributes[0]->newInstance()->help ?? ''); + if ('' === $this->help) { + $this->setHelp($attribute?->help ?? ''); } if (\is_callable($this)) { diff --git a/DependencyInjection/AddConsoleCommandPass.php b/DependencyInjection/AddConsoleCommandPass.php index 248ad3276..a90fb8f04 100644 --- a/DependencyInjection/AddConsoleCommandPass.php +++ b/DependencyInjection/AddConsoleCommandPass.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\DependencyInjection; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; @@ -57,7 +58,18 @@ public function process(ContainerBuilder $container): void $invokableRef = null; } - $aliases = $tags[0]['command'] ?? str_replace('%', '%%', $class::getDefaultName() ?? ''); + /** @var AsCommand|null $attribute */ + $attribute = ($r->getAttributes(AsCommand::class)[0] ?? null)?->newInstance(); + + if (Command::class !== (new \ReflectionMethod($class, 'getDefaultName'))->class) { + trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultName()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', $class); + + $defaultName = $class::getDefaultName(); + } else { + $defaultName = $attribute?->name; + } + + $aliases = str_replace('%', '%%', $tags[0]['command'] ?? $defaultName ?? ''); $aliases = explode('|', $aliases); $commandName = array_shift($aliases); @@ -111,10 +123,18 @@ public function process(ContainerBuilder $container): void $definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]); } - $description ??= str_replace('%', '%%', $class::getDefaultDescription() ?? ''); + if (!$description) { + if (Command::class !== (new \ReflectionMethod($class, 'getDefaultDescription'))->class) { + trigger_deprecation('symfony/console', '7.3', 'Overriding "Command::getDefaultDescription()" in "%s" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.', $class); + + $description = $class::getDefaultDescription(); + } else { + $description = $attribute?->description; + } + } if ($description) { - $definition->addMethodCall('setDescription', [$description]); + $definition->addMethodCall('setDescription', [str_replace('%', '%%', $description)]); $container->register('.'.$id.'.lazy', LazyCommand::class) ->setArguments([$commandName, $aliases, $description, $isHidden, new ServiceClosureArgument($lazyCommandRefs[$id])]); diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 4f6e6cb96..7549a1d8a 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -2404,7 +2404,7 @@ private function createSignalableApplication(Command $command, ?EventDispatcherI if ($dispatcher) { $application->setDispatcher($dispatcher); } - $application->add(new LazyCommand($command::getDefaultName(), [], '', false, fn () => $command, true)); + $application->add(new LazyCommand($command->getName(), [], '', false, fn () => $command, true)); return $application; } diff --git a/Tests/Command/CommandTest.php b/Tests/Command/CommandTest.php index ef6f04c2d..e417b0656 100644 --- a/Tests/Command/CommandTest.php +++ b/Tests/Command/CommandTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Console\Tests\Command; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -28,6 +29,8 @@ class CommandTest extends TestCase { + use ExpectDeprecationTrait; + protected static string $fixturesPath; public static function setUpBeforeClass(): void @@ -427,9 +430,6 @@ public function testSetCodeWithStaticAnonymousFunction() public function testCommandAttribute() { - $this->assertSame('|foo|f', Php8Command::getDefaultName()); - $this->assertSame('desc', Php8Command::getDefaultDescription()); - $command = new Php8Command(); $this->assertSame('foo', $command->getName()); @@ -439,30 +439,62 @@ public function testCommandAttribute() $this->assertSame(['f'], $command->getAliases()); } - public function testAttributeOverridesProperty() + /** + * @group legacy + */ + public function testCommandAttributeWithDeprecatedMethods() { - $this->assertSame('my:command', MyAnnotatedCommand::getDefaultName()); - $this->assertSame('This is a command I wrote all by myself', MyAnnotatedCommand::getDefaultDescription()); + $this->expectDeprecation('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultName()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectDeprecation('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultDescription()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->assertSame('|foo|f', Php8Command::getDefaultName()); + $this->assertSame('desc', Php8Command::getDefaultDescription()); + } + + public function testAttributeOverridesProperty() + { $command = new MyAnnotatedCommand(); $this->assertSame('my:command', $command->getName()); $this->assertSame('This is a command I wrote all by myself', $command->getDescription()); } + /** + * @group legacy + */ + public function testAttributeOverridesPropertyWithDeprecatedMethods() + { + $this->expectDeprecation('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultName()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectDeprecation('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultDescription()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + + $this->assertSame('my:command', MyAnnotatedCommand::getDefaultName()); + $this->assertSame('This is a command I wrote all by myself', MyAnnotatedCommand::getDefaultDescription()); + } + public function testDefaultCommand() { $apl = new Application(); - $apl->setDefaultCommand(Php8Command::getDefaultName()); + $apl->setDefaultCommand('foo'); $property = new \ReflectionProperty($apl, 'defaultCommand'); $this->assertEquals('foo', $property->getValue($apl)); - $apl->setDefaultCommand(Php8Command2::getDefaultName()); + $apl->setDefaultCommand('foo2'); $property = new \ReflectionProperty($apl, 'defaultCommand'); $this->assertEquals('foo2', $property->getValue($apl)); } + + /** + * @group legacy + */ + public function testDeprecatedMethods() + { + $this->expectDeprecation('Since symfony/console 7.3: Overriding "Command::getDefaultName()" in "Symfony\Component\Console\Tests\Command\FooCommand" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectDeprecation('Since symfony/console 7.3: Overriding "Command::getDefaultDescription()" in "Symfony\Component\Console\Tests\Command\FooCommand" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + + new FooCommand(); + } } // In order to get an unbound closure, we should create it outside a class @@ -491,3 +523,16 @@ class MyAnnotatedCommand extends Command protected static $defaultDescription = 'This description should be ignored.'; } + +class FooCommand extends Command +{ + public static function getDefaultName(): ?string + { + return 'foo'; + } + + public static function getDefaultDescription(): ?string + { + return 'foo description'; + } +} From eec43776d7ed956cbcc4ce92f7331c72fbf02040 Mon Sep 17 00:00:00 2001 From: Alexander Menk Date: Thu, 30 Jan 2025 16:55:03 +0100 Subject: [PATCH 11/45] [Console] Add markdown format to Table --- CHANGELOG.md | 1 + Helper/Table.php | 14 ++++++++++++-- Helper/TableStyle.php | 13 +++++++++++++ Tests/Helper/TableTest.php | 14 ++++++++++++++ 4 files changed, 40 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2b64bf1..fb6766ca1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ CHANGELOG * Deprecate not declaring the parameter type in callable commands defined through `setCode` method * Add support for help definition via `AsCommand` attribute * Deprecate methods `Command::getDefaultName()` and `Command::getDefaultDescription()` in favor of the `#[AsCommand]` attribute + * Add support for Markdown format in `Table` 7.2 --- diff --git a/Helper/Table.php b/Helper/Table.php index 9ff73d2cc..2811d58d4 100644 --- a/Helper/Table.php +++ b/Helper/Table.php @@ -417,7 +417,7 @@ public function render(): void continue; } - if ($isHeader && !$isHeaderSeparatorRendered) { + if ($isHeader && !$isHeaderSeparatorRendered && $this->style->displayOutsideBorder()) { $this->renderRowSeparator( self::SEPARATOR_TOP, $hasTitle ? $this->headerTitle : null, @@ -449,7 +449,10 @@ public function render(): void } } } - $this->renderRowSeparator(self::SEPARATOR_BOTTOM, $this->footerTitle, $this->style->getFooterTitleFormat()); + + if ($this->getStyle()->displayOutsideBorder()) { + $this->renderRowSeparator(self::SEPARATOR_BOTTOM, $this->footerTitle, $this->style->getFooterTitleFormat()); + } $this->cleanup(); $this->rendered = true; @@ -868,6 +871,12 @@ private function cleanup(): void */ private static function initStyles(): array { + $markdown = new TableStyle(); + $markdown + ->setDefaultCrossingChar('|') + ->setDisplayOutsideBorder(false) + ; + $borderless = new TableStyle(); $borderless ->setHorizontalBorderChars('=') @@ -905,6 +914,7 @@ private static function initStyles(): array return [ 'default' => new TableStyle(), + 'markdown' => $markdown, 'borderless' => $borderless, 'compact' => $compact, 'symfony-style-guide' => $styleGuide, diff --git a/Helper/TableStyle.php b/Helper/TableStyle.php index be956c109..74ac58925 100644 --- a/Helper/TableStyle.php +++ b/Helper/TableStyle.php @@ -46,6 +46,7 @@ class TableStyle private string $cellRowFormat = '%s'; private string $cellRowContentFormat = ' %s '; private string $borderFormat = '%s'; + private bool $displayOutsideBorder = true; private int $padType = \STR_PAD_RIGHT; /** @@ -359,4 +360,16 @@ public function setFooterTitleFormat(string $format): static return $this; } + + public function setDisplayOutsideBorder($displayOutSideBorder): static + { + $this->displayOutsideBorder = $displayOutSideBorder; + + return $this; + } + + public function displayOutsideBorder(): bool + { + return $this->displayOutsideBorder; + } } diff --git a/Tests/Helper/TableTest.php b/Tests/Helper/TableTest.php index 646c6baca..a50ede664 100644 --- a/Tests/Helper/TableTest.php +++ b/Tests/Helper/TableTest.php @@ -112,6 +112,20 @@ public static function renderProvider() | 80-902734-1-6 | And Then There Were None | Agatha Christie | +---------------+--------------------------+------------------+ +TABLE, + ], + [ + ['ISBN', 'Title', 'Author'], + $books, + 'markdown', + <<<'TABLE' +| ISBN | Title | Author | +|---------------|--------------------------|------------------| +| 99921-58-10-7 | Divine Comedy | Dante Alighieri | +| 9971-5-0210-0 | A Tale of Two Cities | Charles Dickens | +| 960-425-059-0 | The Lord of the Rings | J. R. R. Tolkien | +| 80-902734-1-6 | And Then There Were None | Agatha Christie | + TABLE, ], [ From 4fe37bf6f6096fe31b111e5783cdc4c54b85616a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Andr=C3=A9?= Date: Tue, 31 Dec 2024 17:13:43 +0100 Subject: [PATCH 12/45] [Console] Add a Tree Helper + multiple Styles --- CHANGELOG.md | 2 + Helper/TreeHelper.php | 111 ++++++++++ Helper/TreeNode.php | 105 ++++++++++ Helper/TreeStyle.php | 78 +++++++ Style/SymfonyStyle.php | 21 ++ Tests/Helper/TreeHelperTest.php | 339 +++++++++++++++++++++++++++++++ Tests/Helper/TreeNodeTest.php | 68 +++++++ Tests/Helper/TreeStyleTest.php | 226 +++++++++++++++++++++ Tests/Style/SymfonyStyleTest.php | 95 +++++++++ 9 files changed, 1045 insertions(+) create mode 100644 Helper/TreeHelper.php create mode 100644 Helper/TreeNode.php create mode 100644 Helper/TreeStyle.php create mode 100644 Tests/Helper/TreeHelperTest.php create mode 100644 Tests/Helper/TreeNodeTest.php create mode 100644 Tests/Helper/TreeStyleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index fc2b64bf1..d44d6ad45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ CHANGELOG 7.3 --- + * Add `TreeHelper` and `TreeStyle` to display tree-like structures + * Add `SymfonyStyle::createTree()` * Add support for invokable commands and add `#[Argument]` and `#[Option]` attributes to define input arguments and options * Deprecate not declaring the parameter type in callable commands defined through `setCode` method * Add support for help definition via `AsCommand` attribute diff --git a/Helper/TreeHelper.php b/Helper/TreeHelper.php new file mode 100644 index 000000000..561cd6ccb --- /dev/null +++ b/Helper/TreeHelper.php @@ -0,0 +1,111 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Helper; + +use Symfony\Component\Console\Output\OutputInterface; + +/** + * The TreeHelper class provides methods to display tree-like structures. + * + * @author Simon André + * + * @implements \RecursiveIterator + */ +final class TreeHelper implements \RecursiveIterator +{ + /** + * @var \Iterator + */ + private \Iterator $children; + + private function __construct( + private readonly OutputInterface $output, + private readonly TreeNode $node, + private readonly TreeStyle $style, + ) { + $this->children = new \IteratorIterator($this->node->getChildren()); + $this->children->rewind(); + } + + public static function createTree(OutputInterface $output, string|TreeNode|null $root = null, iterable $values = [], ?TreeStyle $style = null): self + { + $node = $root instanceof TreeNode ? $root : new TreeNode($root ?? ''); + + return new self($output, TreeNode::fromValues($values, $node), $style ?? TreeStyle::default()); + } + + public function current(): TreeNode + { + return $this->children->current(); + } + + public function key(): int + { + return $this->children->key(); + } + + public function next(): void + { + $this->children->next(); + } + + public function rewind(): void + { + $this->children->rewind(); + } + + public function valid(): bool + { + return $this->children->valid(); + } + + public function hasChildren(): bool + { + if (null === $current = $this->current()) { + return false; + } + + foreach ($current->getChildren() as $child) { + return true; + } + + return false; + } + + public function getChildren(): \RecursiveIterator + { + return new self($this->output, $this->current(), $this->style); + } + + /** + * Recursively renders the tree to the output, applying the tree style. + */ + public function render(): void + { + $treeIterator = new \RecursiveTreeIterator($this); + + $this->style->applyPrefixes($treeIterator); + + $this->output->writeln($this->node->getValue()); + + $visited = new \SplObjectStorage(); + foreach ($treeIterator as $node) { + $currentNode = $node instanceof TreeNode ? $node : $treeIterator->getInnerIterator()->current(); + if ($visited->contains($currentNode)) { + throw new \LogicException(\sprintf('Cycle detected at node: "%s".', $currentNode->getValue())); + } + $visited->attach($currentNode); + + $this->output->writeln($node); + } + } +} diff --git a/Helper/TreeNode.php b/Helper/TreeNode.php new file mode 100644 index 000000000..7f2ed8a4a --- /dev/null +++ b/Helper/TreeNode.php @@ -0,0 +1,105 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Helper; + +/** + * @implements \IteratorAggregate + * + * @author Simon André + */ +final class TreeNode implements \Countable, \IteratorAggregate +{ + /** + * @var array + */ + private array $children = []; + + public function __construct( + private readonly string $value = '', + iterable $children = [], + ) { + foreach ($children as $child) { + $this->addChild($child); + } + } + + public static function fromValues(iterable $nodes, ?self $node = null): self + { + $node ??= new self(); + foreach ($nodes as $key => $value) { + if (is_iterable($value)) { + $child = new self($key); + self::fromValues($value, $child); + $node->addChild($child); + } elseif ($value instanceof self) { + $node->addChild($value); + } else { + $node->addChild(new self($value)); + } + } + + return $node; + } + + public function getValue(): string + { + return $this->value; + } + + public function addChild(self|string|callable $node): self + { + if (\is_string($node)) { + $node = new self($node, $this); + } + + $this->children[] = $node; + + return $this; + } + + /** + * @return \Traversable + */ + public function getChildren(): \Traversable + { + foreach ($this->children as $child) { + if (\is_callable($child)) { + yield from $child(); + } elseif ($child instanceof self) { + yield $child; + } + } + } + + /** + * @return \Traversable + */ + public function getIterator(): \Traversable + { + return $this->getChildren(); + } + + public function count(): int + { + $count = 0; + foreach ($this->getChildren() as $child) { + ++$count; + } + + return $count; + } + + public function __toString(): string + { + return $this->value; + } +} diff --git a/Helper/TreeStyle.php b/Helper/TreeStyle.php new file mode 100644 index 000000000..21cc04b3c --- /dev/null +++ b/Helper/TreeStyle.php @@ -0,0 +1,78 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Helper; + +/** + * Configures the output of the Tree helper. + * + * @author Simon André + */ +final class TreeStyle +{ + public function __construct( + private readonly string $prefixEndHasNext, + private readonly string $prefixEndLast, + private readonly string $prefixLeft, + private readonly string $prefixMidHasNext, + private readonly string $prefixMidLast, + private readonly string $prefixRight, + ) { + } + + public static function box(): self + { + return new self('┃╸ ', '┗╸ ', '', '┃ ', ' ', ''); + } + + public static function boxDouble(): self + { + return new self('╠═ ', '╚═ ', '', '║ ', ' ', ''); + } + + public static function compact(): self + { + return new self('├ ', '└ ', '', '│ ', ' ', ''); + } + + public static function default(): self + { + return new self('├── ', '└── ', '', '│ ', ' ', ''); + } + + public static function light(): self + { + return new self('|-- ', '`-- ', '', '| ', ' ', ''); + } + + public static function minimal(): self + { + return new self('. ', '. ', '', '. ', ' ', ''); + } + + public static function rounded(): self + { + return new self('├─ ', '╰─ ', '', '│ ', ' ', ''); + } + + /** + * @internal + */ + public function applyPrefixes(\RecursiveTreeIterator $iterator): void + { + $iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_LEFT, $this->prefixLeft); + $iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_MID_HAS_NEXT, $this->prefixMidHasNext); + $iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_MID_LAST, $this->prefixMidLast); + $iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_HAS_NEXT, $this->prefixEndHasNext); + $iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_END_LAST, $this->prefixEndLast); + $iterator->setPrefixPart(\RecursiveTreeIterator::PREFIX_RIGHT, $this->prefixRight); + } +} diff --git a/Style/SymfonyStyle.php b/Style/SymfonyStyle.php index 4cf62cdba..d0788e88d 100644 --- a/Style/SymfonyStyle.php +++ b/Style/SymfonyStyle.php @@ -21,6 +21,9 @@ use Symfony\Component\Console\Helper\Table; use Symfony\Component\Console\Helper\TableCell; use Symfony\Component\Console\Helper\TableSeparator; +use Symfony\Component\Console\Helper\TreeHelper; +use Symfony\Component\Console\Helper\TreeNode; +use Symfony\Component\Console\Helper\TreeStyle; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\ConsoleSectionOutput; @@ -369,6 +372,24 @@ private function getProgressBar(): ProgressBar ?? throw new RuntimeException('The ProgressBar is not started.'); } + /** + * @param iterable $nodes + */ + public function tree(iterable $nodes, string $root = ''): void + { + $this->createTree($nodes, $root)->render(); + } + + /** + * @param iterable $nodes + */ + public function createTree(iterable $nodes, string $root = ''): TreeHelper + { + $output = $this->output instanceof ConsoleOutputInterface ? $this->output->section() : $this->output; + + return TreeHelper::createTree($output, $root, $nodes, TreeStyle::default()); + } + private function autoPrependBlock(): void { $chars = substr(str_replace(\PHP_EOL, "\n", $this->bufferedOutput->fetch()), -2); diff --git a/Tests/Helper/TreeHelperTest.php b/Tests/Helper/TreeHelperTest.php new file mode 100644 index 000000000..e7e1b54ae --- /dev/null +++ b/Tests/Helper/TreeHelperTest.php @@ -0,0 +1,339 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Helper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Helper\TreeHelper; +use Symfony\Component\Console\Helper\TreeNode; +use Symfony\Component\Console\Helper\TreeStyle; +use Symfony\Component\Console\Output\BufferedOutput; + +class TreeHelperTest extends TestCase +{ + public function testRenderWithoutNode() + { + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output); + + $tree->render(); + $this->assertSame("\n", $output->fetch()); + } + + public function testRenderSingleNode() + { + $rootNode = new TreeNode('Root'); + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame("Root\n", $output->fetch()); + } + + public function testRenderTwoLevelTree() + { + $rootNode = new TreeNode('Root'); + $child1 = new TreeNode('Child 1'); + $child2 = new TreeNode('Child 2'); + + $rootNode->addChild($child1); + $rootNode->addChild($child2); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderThreeLevelTree() + { + $rootNode = new TreeNode('Root'); + $child1 = new TreeNode('Child 1'); + $child2 = new TreeNode('Child 2'); + $subChild1 = new TreeNode('SubChild 1'); + + $child1->addChild($subChild1); + $rootNode->addChild($child1); + $rootNode->addChild($child2); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderMultiLevelTree() + { + $rootNode = new TreeNode('Root'); + $child1 = new TreeNode('Child 1'); + $child2 = new TreeNode('Child 2'); + $subChild1 = new TreeNode('SubChild 1'); + $subChild2 = new TreeNode('SubChild 2'); + $subSubChild1 = new TreeNode('SubSubChild 1'); + + $subChild1->addChild($subSubChild1); + $child1->addChild($subChild1); + $child1->addChild($subChild2); + $rootNode->addChild($child1); + $rootNode->addChild($child2); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderSingleNodeTree() + { + $rootNode = new TreeNode('Root'); + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderEmptyTree() + { + $rootNode = new TreeNode('Root'); + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderDeeplyNestedTree() + { + $rootNode = new TreeNode('Root'); + $current = $rootNode; + for ($i = 1; $i <= 10; ++$i) { + $child = new TreeNode("Level $i"); + $current->addChild($child); + $current = $child; + } + + $style = new TreeStyle(...[ + '└── ', + '└── ', + '', + ' ', + ' ', + '', + ]); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode, [], $style); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderNodeWithMultipleChildren() + { + $rootNode = new TreeNode('Root'); + $child1 = new TreeNode('Child 1'); + $child2 = new TreeNode('Child 2'); + $child3 = new TreeNode('Child 3'); + + $rootNode->addChild($child1); + $rootNode->addChild($child2); + $rootNode->addChild($child3); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderTreeWithDuplicateNodeNames() + { + $rootNode = new TreeNode('Root'); + $child1 = new TreeNode('Child'); + $child2 = new TreeNode('Child'); + $subChild1 = new TreeNode('Child'); + + $child1->addChild($subChild1); + $rootNode->addChild($child1); + $rootNode->addChild($child2); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderTreeWithComplexNodeNames() + { + $rootNode = new TreeNode('Root'); + $child1 = new TreeNode('Child 1 (special)'); + $child2 = new TreeNode('Child_2@#$'); + $subChild1 = new TreeNode('Node with spaces'); + + $child1->addChild($subChild1); + $rootNode->addChild($child1); + $rootNode->addChild($child2); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testRenderTreeWithCycle() + { + $rootNode = new TreeNode('Root'); + $child1 = new TreeNode('Child 1'); + $child2 = new TreeNode('Child 2'); + + $child1->addChild($child2); + // Create a cycle voluntarily + $child2->addChild($child1); + + $rootNode->addChild($child1); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $this->expectException(\LogicException::class); + $tree->render(); + } + + public function testRenderWideTree() + { + $rootNode = new TreeNode('Root'); + for ($i = 1; $i <= 100; ++$i) { + $rootNode->addChild(new TreeNode("Child $i")); + } + + $output = new BufferedOutput(); + + $tree = TreeHelper::createTree($output, $rootNode); + $tree->render(); + + $lines = explode("\n", trim($output->fetch())); + $this->assertCount(101, $lines); + $this->assertSame('Root', $lines[0]); + $this->assertSame('└── Child 100', end($lines)); + } + + public function testCreateWithRoot() + { + $output = new BufferedOutput(); + $array = ['child1', 'child2']; + + $tree = TreeHelper::createTree($output, 'root', $array); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testCreateWithNestedArray() + { + $output = new BufferedOutput(); + $array = ['child1', 'child2' => ['child2.1', 'child2.2' => ['child2.2.1']], 'child3']; + + $tree = TreeHelper::createTree($output, 'root', $array); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testCreateWithoutRoot() + { + $output = new BufferedOutput(); + $array = ['child1', 'child2']; + + $tree = TreeHelper::createTree($output, null, $array); + + $tree->render(); + $this->assertSame(<<fetch())); + } + + public function testCreateWithEmptyArray() + { + $output = new BufferedOutput(); + $array = []; + + $tree = TreeHelper::createTree($output, null, $array); + + $tree->render(); + $this->assertSame('', trim($output->fetch())); + } +} diff --git a/Tests/Helper/TreeNodeTest.php b/Tests/Helper/TreeNodeTest.php new file mode 100644 index 000000000..981e7ea47 --- /dev/null +++ b/Tests/Helper/TreeNodeTest.php @@ -0,0 +1,68 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Helper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Helper\TreeNode; + +class TreeNodeTest extends TestCase +{ + public function testNodeInitialization() + { + $node = new TreeNode('Root'); + $this->assertSame('Root', $node->getValue()); + $this->assertSame(0, iterator_count($node->getChildren())); + } + + public function testAddingChildren() + { + $root = new TreeNode('Root'); + $child = new TreeNode('Child'); + + $root->addChild($child); + + $this->assertSame(1, iterator_count($root->getChildren())); + $this->assertSame($child, iterator_to_array($root->getChildren())[0]); + } + + public function testAddingChildrenWithGenerators() + { + $root = new TreeNode('Root'); + + $root->addChild(function () { + yield new TreeNode('Generated Child 1'); + yield new TreeNode('Generated Child 2'); + }); + + $this->assertSame(2, iterator_count($root->getChildren())); + + $children = iterator_to_array($root->getChildren()); + + $this->assertSame('Generated Child 1', $children[0]->getValue()); + $this->assertSame('Generated Child 2', $children[1]->getValue()); + } + + public function testRecursiveStructure() + { + $root = new TreeNode('Root'); + $child1 = new TreeNode('Child 1'); + $child2 = new TreeNode('Child 2'); + $leaf1 = new TreeNode('Leaf 1'); + + $child1->addChild($leaf1); + $root->addChild($child1); + $root->addChild($child2); + + $this->assertSame(2, iterator_count($root->getChildren())); + $this->assertSame($leaf1, iterator_to_array($child1->getChildren())[0]); + } +} diff --git a/Tests/Helper/TreeStyleTest.php b/Tests/Helper/TreeStyleTest.php new file mode 100644 index 000000000..216931f9c --- /dev/null +++ b/Tests/Helper/TreeStyleTest.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Tests\Helper; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Helper\TreeHelper; +use Symfony\Component\Console\Helper\TreeNode; +use Symfony\Component\Console\Helper\TreeStyle; +use Symfony\Component\Console\Output\BufferedOutput; +use Symfony\Component\Console\Output\OutputInterface; + +class TreeStyleTest extends TestCase +{ + public function testDefaultStyle() + { + $output = new BufferedOutput(); + $tree = self::createTree($output); + + $tree->render(); + + $this->assertSame(<<fetch())); + } + + public function testBoxStyle() + { + $output = new BufferedOutput(); + $this->createTree($output, TreeStyle::box())->render(); + + $this->assertSame(<<fetch())); + } + + public function testBoxDoubleStyle() + { + $output = new BufferedOutput(); + $this->createTree($output, TreeStyle::boxDouble())->render(); + + $this->assertSame(<<fetch())); + } + + public function testCompactStyle() + { + $output = new BufferedOutput(); + $this->createTree($output, TreeStyle::compact())->render(); + + $this->assertSame(<<<'TREE' +root +├ A +│ ├ A1 +│ └ A2 +│ └ A2.1 +│ ├ A2.1.1 +│ └ A2.1.2 +├ B +│ ├ B1 +│ │ ├ B11 +│ │ └ B12 +│ └ B2 +└ C +TREE, trim($output->fetch())); + } + + public function testLightStyle() + { + $output = new BufferedOutput(); + $this->createTree($output, TreeStyle::light())->render(); + + $this->assertSame(<<<'TREE' +root +|-- A +| |-- A1 +| `-- A2 +| `-- A2.1 +| |-- A2.1.1 +| `-- A2.1.2 +|-- B +| |-- B1 +| | |-- B11 +| | `-- B12 +| `-- B2 +`-- C +TREE, trim($output->fetch())); + } + + public function testMinimalStyle() + { + $output = new BufferedOutput(); + $this->createTree($output, TreeStyle::minimal())->render(); + + $this->assertSame(<<<'TREE' +root +. A +. . A1 +. . A2 +. . A2.1 +. . A2.1.1 +. . A2.1.2 +. B +. . B1 +. . . B11 +. . . B12 +. . B2 +. C +TREE, trim($output->fetch())); + } + + public function testRoundedStyle() + { + $output = new BufferedOutput(); + $this->createTree($output, TreeStyle::rounded())->render(); + + $this->assertSame(<<<'TREE' +root +├─ A +│ ├─ A1 +│ ╰─ A2 +│ ╰─ A2.1 +│ ├─ A2.1.1 +│ ╰─ A2.1.2 +├─ B +│ ├─ B1 +│ │ ├─ B11 +│ │ ╰─ B12 +│ ╰─ B2 +╰─ C +TREE, trim($output->fetch())); + } + + public function testCustomPrefix() + { + $style = new TreeStyle('A ', 'B ', 'C ', 'D ', 'E ', 'F '); + $output = new BufferedOutput(); + self::createTree($output, $style)->render(); + + $this->assertSame(<<<'TREE' +root +C A F A +C D A F A1 +C D B F A2 +C D E B F A2.1 +C D E E A F A2.1.1 +C D E E B F A2.1.2 +C A F B +C D A F B1 +C D D A F B11 +C D D B F B12 +C D B F B2 +C B F C +TREE, trim($output->fetch())); + } + + private static function createTree(OutputInterface $output, ?TreeStyle $style = null): TreeHelper + { + $root = new TreeNode('root'); + $root + ->addChild((new TreeNode('A')) + ->addChild(new TreeNode('A1')) + ->addChild((new TreeNode('A2')) + ->addChild((new TreeNode('A2.1')) + ->addChild(new TreeNode('A2.1.1')) + ->addChild(new TreeNode('A2.1.2')) + ) + ) + ) + ->addChild((new TreeNode('B')) + ->addChild((new TreeNode('B1')) + ->addChild(new TreeNode('B11')) + ->addChild(new TreeNode('B12')) + ) + ->addChild(new TreeNode('B2')) + ) + ->addChild(new TreeNode('C')); + + return TreeHelper::createTree($output, $root, [], $style); + } +} diff --git a/Tests/Style/SymfonyStyleTest.php b/Tests/Style/SymfonyStyleTest.php index 0b40c7c3f..7ad08d4ad 100644 --- a/Tests/Style/SymfonyStyleTest.php +++ b/Tests/Style/SymfonyStyleTest.php @@ -15,9 +15,11 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\RuntimeException; use Symfony\Component\Console\Formatter\OutputFormatter; +use Symfony\Component\Console\Helper\TreeHelper; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\Input; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\ConsoleSectionOutput; use Symfony\Component\Console\Output\NullOutput; @@ -154,6 +156,99 @@ public function testCreateTableWithoutConsoleOutput() $style->createTable()->appendRow(['row']); } + public function testCreateTree() + { + $output = $this->createMock(OutputInterface::class); + $output + ->method('getFormatter') + ->willReturn(new OutputFormatter()); + + $style = new SymfonyStyle($this->createMock(InputInterface::class), $output); + + $tree = $style->createTree([]); + $this->assertInstanceOf(TreeHelper::class, $tree); + } + + public function testTree() + { + $input = $this->createMock(InputInterface::class); + $output = new BufferedOutput(); + $style = new SymfonyStyle($input, $output); + + $tree = $style->createTree(['A', 'B' => ['B1' => ['B11', 'B12'], 'B2'], 'C'], 'root'); + $tree->render(); + + $this->assertSame(<<fetch())); + } + + public function testCreateTreeWithArray() + { + $input = $this->createMock(InputInterface::class); + $output = new BufferedOutput(); + $style = new SymfonyStyle($input, $output); + + $tree = $style->createTree(['A', 'B' => ['B1' => ['B11', 'B12'], 'B2'], 'C'], 'root'); + $tree->render(); + + $this->assertSame($tree = <<fetch())); + } + + public function testCreateTreeWithIterable() + { + $input = $this->createMock(InputInterface::class); + $output = new BufferedOutput(); + $style = new SymfonyStyle($input, $output); + + $tree = $style->createTree(new \ArrayIterator(['A', 'B' => ['B1' => ['B11', 'B12'], 'B2'], 'C']), 'root'); + $tree->render(); + + $this->assertSame(<<fetch())); + } + + public function testCreateTreeWithConsoleOutput() + { + $input = $this->createMock(InputInterface::class); + $output = $this->createMock(ConsoleOutputInterface::class); + $output + ->method('getFormatter') + ->willReturn(new OutputFormatter()); + $output + ->expects($this->once()) + ->method('section') + ->willReturn($this->createMock(ConsoleSectionOutput::class)); + + $style = new SymfonyStyle($input, $output); + + $style->createTree([]); + } + public function testGetErrorStyleUsesTheCurrentOutputIfNoErrorOutputIsAvailable() { $output = $this->createMock(OutputInterface::class); From d18014c50557f16818ccf0d888f6dc72b060f004 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Sun, 2 Mar 2025 14:38:09 +0100 Subject: [PATCH 13/45] fix tests on Windows --- Tests/Helper/TreeHelperTest.php | 37 ++++++++++++++++++-------------- Tests/Helper/TreeStyleTest.php | 21 +++++++++++------- Tests/Style/SymfonyStyleTest.php | 11 +++++++--- 3 files changed, 42 insertions(+), 27 deletions(-) diff --git a/Tests/Helper/TreeHelperTest.php b/Tests/Helper/TreeHelperTest.php index e7e1b54ae..11cc54bad 100644 --- a/Tests/Helper/TreeHelperTest.php +++ b/Tests/Helper/TreeHelperTest.php @@ -25,7 +25,7 @@ public function testRenderWithoutNode() $tree = TreeHelper::createTree($output); $tree->render(); - $this->assertSame("\n", $output->fetch()); + $this->assertSame(PHP_EOL, $output->fetch()); } public function testRenderSingleNode() @@ -35,7 +35,7 @@ public function testRenderSingleNode() $tree = TreeHelper::createTree($output, $rootNode); $tree->render(); - $this->assertSame("Root\n", $output->fetch()); + $this->assertSame("Root\n", self::normalizeLineBreaks($output->fetch())); } public function testRenderTwoLevelTree() @@ -55,7 +55,7 @@ public function testRenderTwoLevelTree() Root ├── Child 1 └── Child 2 -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderThreeLevelTree() @@ -78,7 +78,7 @@ public function testRenderThreeLevelTree() ├── Child 1 │ └── SubChild 1 └── Child 2 -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderMultiLevelTree() @@ -107,7 +107,7 @@ public function testRenderMultiLevelTree() │ │ └── SubSubChild 1 │ └── SubChild 2 └── Child 2 -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderSingleNodeTree() @@ -119,7 +119,7 @@ public function testRenderSingleNodeTree() $tree->render(); $this->assertSame(<<fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderEmptyTree() @@ -131,7 +131,7 @@ public function testRenderEmptyTree() $tree->render(); $this->assertSame(<<fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderDeeplyNestedTree() @@ -169,7 +169,7 @@ public function testRenderDeeplyNestedTree() └── Level 8 └── Level 9 └── Level 10 -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderNodeWithMultipleChildren() @@ -192,7 +192,7 @@ public function testRenderNodeWithMultipleChildren() ├── Child 1 ├── Child 2 └── Child 3 -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderTreeWithDuplicateNodeNames() @@ -215,7 +215,7 @@ public function testRenderTreeWithDuplicateNodeNames() ├── Child │ └── Child └── Child -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderTreeWithComplexNodeNames() @@ -238,7 +238,7 @@ public function testRenderTreeWithComplexNodeNames() ├── Child 1 (special) │ └── Node with spaces └── Child_2@#$ -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRenderTreeWithCycle() @@ -272,7 +272,7 @@ public function testRenderWideTree() $tree = TreeHelper::createTree($output, $rootNode); $tree->render(); - $lines = explode("\n", trim($output->fetch())); + $lines = explode("\n", self::normalizeLineBreaks(trim($output->fetch()))); $this->assertCount(101, $lines); $this->assertSame('Root', $lines[0]); $this->assertSame('└── Child 100', end($lines)); @@ -290,7 +290,7 @@ public function testCreateWithRoot() root ├── child1 └── child2 -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testCreateWithNestedArray() @@ -309,7 +309,7 @@ public function testCreateWithNestedArray() │ └── child2.2 │ └── child2.2.1 └── child3 -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testCreateWithoutRoot() @@ -323,7 +323,7 @@ public function testCreateWithoutRoot() $this->assertSame(<<fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testCreateWithEmptyArray() @@ -334,6 +334,11 @@ public function testCreateWithEmptyArray() $tree = TreeHelper::createTree($output, null, $array); $tree->render(); - $this->assertSame('', trim($output->fetch())); + $this->assertSame('', self::normalizeLineBreaks(trim($output->fetch()))); + } + + private static function normalizeLineBreaks($text) + { + return str_replace(\PHP_EOL, "\n", $text); } } diff --git a/Tests/Helper/TreeStyleTest.php b/Tests/Helper/TreeStyleTest.php index 216931f9c..7f5bfedd3 100644 --- a/Tests/Helper/TreeStyleTest.php +++ b/Tests/Helper/TreeStyleTest.php @@ -41,7 +41,7 @@ public function testDefaultStyle() │ │ └── B12 │ └── B2 └── C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testBoxStyle() @@ -63,7 +63,7 @@ public function testBoxStyle() ┃ ┃ ┗╸ B12 ┃ ┗╸ B2 ┗╸ C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testBoxDoubleStyle() @@ -85,7 +85,7 @@ public function testBoxDoubleStyle() ║ ║ ╚═ B12 ║ ╚═ B2 ╚═ C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testCompactStyle() @@ -107,7 +107,7 @@ public function testCompactStyle() │ │ └ B12 │ └ B2 └ C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testLightStyle() @@ -129,7 +129,7 @@ public function testLightStyle() | | `-- B12 | `-- B2 `-- C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testMinimalStyle() @@ -151,7 +151,7 @@ public function testMinimalStyle() . . . B12 . . B2 . C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testRoundedStyle() @@ -173,7 +173,7 @@ public function testRoundedStyle() │ │ ╰─ B12 │ ╰─ B2 ╰─ C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testCustomPrefix() @@ -196,7 +196,7 @@ public function testCustomPrefix() C D D B F B12 C D B F B2 C B F C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } private static function createTree(OutputInterface $output, ?TreeStyle $style = null): TreeHelper @@ -223,4 +223,9 @@ private static function createTree(OutputInterface $output, ?TreeStyle $style = return TreeHelper::createTree($output, $root, [], $style); } + + private static function normalizeLineBreaks($text) + { + return str_replace(\PHP_EOL, "\n", $text); + } } diff --git a/Tests/Style/SymfonyStyleTest.php b/Tests/Style/SymfonyStyleTest.php index 7ad08d4ad..a3b7ae406 100644 --- a/Tests/Style/SymfonyStyleTest.php +++ b/Tests/Style/SymfonyStyleTest.php @@ -187,7 +187,7 @@ public function testTree() │ │ └── B12 │ └── B2 └── C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testCreateTreeWithArray() @@ -208,7 +208,7 @@ public function testCreateTreeWithArray() │ │ └── B12 │ └── B2 └── C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testCreateTreeWithIterable() @@ -229,7 +229,7 @@ public function testCreateTreeWithIterable() │ │ └── B12 │ └── B2 └── C -TREE, trim($output->fetch())); +TREE, self::normalizeLineBreaks(trim($output->fetch()))); } public function testCreateTreeWithConsoleOutput() @@ -314,4 +314,9 @@ public function testAskAndClearExpectFullSectionCleared() escapeshellcmd(stream_get_contents($output->getStream())) ); } + + private static function normalizeLineBreaks($text) + { + return str_replace(\PHP_EOL, "\n", $text); + } } From fd8597ef1140cc412b3e9a93fe161fec87ed6054 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Sun, 2 Mar 2025 16:03:52 +0100 Subject: [PATCH 14/45] replace assertEmpty() with stricter assertions --- Tests/ApplicationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 7549a1d8a..835195743 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -291,7 +291,7 @@ public function testSilentHelp() $tester = new ApplicationTester($application); $tester->run(['-h' => true, '-q' => true], ['decorated' => false]); - $this->assertEmpty($tester->getDisplay(true)); + $this->assertSame('', $tester->getDisplay(true)); } public function testGetInvalidCommand() From aa340d80a7057678cc0f37e29a9e1b582a93a6b1 Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Sat, 8 Mar 2025 00:16:33 +0100 Subject: [PATCH 15/45] chore: PHP CS Fixer fixes --- Formatter/OutputFormatter.php | 1 - Helper/QuestionHelper.php | 1 - 2 files changed, 2 deletions(-) diff --git a/Formatter/OutputFormatter.php b/Formatter/OutputFormatter.php index 3c8c287e8..8fcbf49d7 100644 --- a/Formatter/OutputFormatter.php +++ b/Formatter/OutputFormatter.php @@ -12,7 +12,6 @@ namespace Symfony\Component\Console\Formatter; use Symfony\Component\Console\Exception\InvalidArgumentException; - use function Symfony\Component\String\b; /** diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 8e1591ec1..fa50f0068 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -24,7 +24,6 @@ use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Terminal; - use function Symfony\Component\String\s; /** From 035746ba339c0fcea9d1af1d018cbb0f4c49ba8f Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Fri, 14 Mar 2025 19:20:21 -0400 Subject: [PATCH 16/45] Fixed support for Kernel as command --- DependencyInjection/AddConsoleCommandPass.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/DependencyInjection/AddConsoleCommandPass.php b/DependencyInjection/AddConsoleCommandPass.php index a90fb8f04..562627f4b 100644 --- a/DependencyInjection/AddConsoleCommandPass.php +++ b/DependencyInjection/AddConsoleCommandPass.php @@ -39,7 +39,6 @@ public function process(ContainerBuilder $container): void foreach ($commandServices as $id => $tags) { $definition = $container->getDefinition($id); - $definition->addTag('container.no_preload'); $class = $container->getParameterBag()->resolveValue($definition->getClass()); if (!$r = $container->getReflectionClass($class)) { @@ -58,6 +57,8 @@ public function process(ContainerBuilder $container): void $invokableRef = null; } + $definition->addTag('container.no_preload'); + /** @var AsCommand|null $attribute */ $attribute = ($r->getAttributes(AsCommand::class)[0] ?? null)?->newInstance(); From 04cc4bca41a6b458a8d87fdb5c0c0a78f14d123c Mon Sep 17 00:00:00 2001 From: Dariusz Ruminski Date: Thu, 13 Mar 2025 23:58:12 +0100 Subject: [PATCH 17/45] chore: PHP CS Fixer fixes --- Formatter/OutputFormatter.php | 1 + Helper/QuestionHelper.php | 1 + Tests/Helper/TreeHelperTest.php | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Formatter/OutputFormatter.php b/Formatter/OutputFormatter.php index 8fcbf49d7..3c8c287e8 100644 --- a/Formatter/OutputFormatter.php +++ b/Formatter/OutputFormatter.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Console\Formatter; use Symfony\Component\Console\Exception\InvalidArgumentException; + use function Symfony\Component\String\b; /** diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index fa50f0068..8e1591ec1 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -24,6 +24,7 @@ use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Terminal; + use function Symfony\Component\String\s; /** diff --git a/Tests/Helper/TreeHelperTest.php b/Tests/Helper/TreeHelperTest.php index 11cc54bad..15ec0f0b2 100644 --- a/Tests/Helper/TreeHelperTest.php +++ b/Tests/Helper/TreeHelperTest.php @@ -25,7 +25,7 @@ public function testRenderWithoutNode() $tree = TreeHelper::createTree($output); $tree->render(); - $this->assertSame(PHP_EOL, $output->fetch()); + $this->assertSame(\PHP_EOL, $output->fetch()); } public function testRenderSingleNode() From fcf020598209229f80cb3bbd264669b8b9515b98 Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Sun, 23 Mar 2025 10:38:59 -0400 Subject: [PATCH 18/45] Add support for invokable commands in LockableTrait --- CHANGELOG.md | 1 + Command/LockableTrait.php | 15 ++++++++++++-- Tests/Command/LockableTraitTest.php | 23 +++++++++++++++++++++ Tests/Fixtures/FooLock4InvokableCommand.php | 22 ++++++++++++++++++++ 4 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 Tests/Fixtures/FooLock4InvokableCommand.php diff --git a/CHANGELOG.md b/CHANGELOG.md index b5db6abec..d0ee13c44 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ CHANGELOG * Add support for help definition via `AsCommand` attribute * Deprecate methods `Command::getDefaultName()` and `Command::getDefaultDescription()` in favor of the `#[AsCommand]` attribute * Add support for Markdown format in `Table` + * Add support for `LockableTrait` in invokable commands 7.2 --- diff --git a/Command/LockableTrait.php b/Command/LockableTrait.php index f0001cc52..b7abd2fdc 100644 --- a/Command/LockableTrait.php +++ b/Command/LockableTrait.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Console\Command; +use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\LockInterface; @@ -48,10 +49,20 @@ private function lock(?string $name = null, bool $blocking = false): bool $store = new FlockStore(); } - $this->lockFactory = (new LockFactory($store)); + $this->lockFactory = new LockFactory($store); } - $this->lock = $this->lockFactory->createLock($name ?: $this->getName()); + if (!$name) { + if ($this instanceof Command) { + $name = $this->getName(); + } elseif ($attribute = (new \ReflectionClass($this::class))->getAttributes(AsCommand::class)) { + $name = $attribute[0]->newInstance()->name; + } else { + throw new LogicException(\sprintf('Lock name missing: provide it via "%s()", #[AsCommand] attribute, or by extending Command class.', __METHOD__)); + } + } + + $this->lock = $this->lockFactory->createLock($name); if (!$this->lock->acquire($blocking)) { $this->lock = null; diff --git a/Tests/Command/LockableTraitTest.php b/Tests/Command/LockableTraitTest.php index 0268d9681..3000906d7 100644 --- a/Tests/Command/LockableTraitTest.php +++ b/Tests/Command/LockableTraitTest.php @@ -12,6 +12,7 @@ namespace Symfony\Component\Console\Tests\Command; use PHPUnit\Framework\TestCase; +use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Tester\CommandTester; use Symfony\Component\Lock\LockFactory; use Symfony\Component\Lock\SharedLockInterface; @@ -28,6 +29,7 @@ public static function setUpBeforeClass(): void require_once self::$fixturesPath.'/FooLockCommand.php'; require_once self::$fixturesPath.'/FooLock2Command.php'; require_once self::$fixturesPath.'/FooLock3Command.php'; + require_once self::$fixturesPath.'/FooLock4InvokableCommand.php'; } public function testLockIsReleased() @@ -80,4 +82,25 @@ public function testCustomLockFactoryIsUsed() $lockFactory->expects(static::once())->method('createLock')->willReturn($lock); $this->assertSame(1, $tester->execute([])); } + + public function testLockInvokableCommandReturnsFalseIfAlreadyLockedByAnotherCommand() + { + $command = new Command('foo:lock4'); + $command->setCode(new \FooLock4InvokableCommand()); + + if (SemaphoreStore::isSupported()) { + $store = new SemaphoreStore(); + } else { + $store = new FlockStore(); + } + + $lock = (new LockFactory($store))->createLock($command->getName()); + $lock->acquire(); + + $tester = new CommandTester($command); + $this->assertSame(Command::FAILURE, $tester->execute([])); + + $lock->release(); + $this->assertSame(Command::SUCCESS, $tester->execute([])); + } } diff --git a/Tests/Fixtures/FooLock4InvokableCommand.php b/Tests/Fixtures/FooLock4InvokableCommand.php new file mode 100644 index 000000000..7309234fa --- /dev/null +++ b/Tests/Fixtures/FooLock4InvokableCommand.php @@ -0,0 +1,22 @@ +lock()) { + return Command::FAILURE; + } + + $this->release(); + + return Command::SUCCESS; + } +} From 163ef97415eed697a673c6078f9c0459442ad996 Mon Sep 17 00:00:00 2001 From: Yonel Ceruto Date: Fri, 28 Mar 2025 09:22:25 -0400 Subject: [PATCH 19/45] Deprecate returning a non-integer value from a `\Closure` function set via `Command::setCode()` --- CHANGELOG.md | 1 + Command/Command.php | 2 +- Command/InvokableCommand.php | 10 +++-- Tests/ApplicationTest.php | 44 ++++++++++++++----- Tests/Command/CommandTest.php | 33 +++++++++++--- Tests/Command/InvokableCommandTest.php | 33 +++++++++++--- .../Style/SymfonyStyle/command/command_0.php | 4 +- .../Style/SymfonyStyle/command/command_1.php | 4 +- .../Style/SymfonyStyle/command/command_10.php | 4 +- .../Style/SymfonyStyle/command/command_11.php | 4 +- .../Style/SymfonyStyle/command/command_12.php | 4 +- .../Style/SymfonyStyle/command/command_13.php | 4 +- .../Style/SymfonyStyle/command/command_14.php | 4 +- .../Style/SymfonyStyle/command/command_15.php | 4 +- .../Style/SymfonyStyle/command/command_16.php | 4 +- .../Style/SymfonyStyle/command/command_17.php | 4 +- .../Style/SymfonyStyle/command/command_18.php | 4 +- .../Style/SymfonyStyle/command/command_19.php | 4 +- .../Style/SymfonyStyle/command/command_2.php | 4 +- .../Style/SymfonyStyle/command/command_20.php | 4 +- .../Style/SymfonyStyle/command/command_21.php | 4 +- .../Style/SymfonyStyle/command/command_22.php | 4 +- .../Style/SymfonyStyle/command/command_23.php | 4 +- .../Style/SymfonyStyle/command/command_3.php | 4 +- .../Style/SymfonyStyle/command/command_4.php | 4 +- .../command/command_4_with_iterators.php | 4 +- .../Style/SymfonyStyle/command/command_5.php | 4 +- .../Style/SymfonyStyle/command/command_6.php | 4 +- .../Style/SymfonyStyle/command/command_7.php | 4 +- .../Style/SymfonyStyle/command/command_8.php | 4 +- .../Style/SymfonyStyle/command/command_9.php | 4 +- .../command/interactive_command_1.php | 4 +- .../progress/command_progress_iterate.php | 4 +- Tests/Fixtures/application_signalable.php | 2 +- Tests/Helper/QuestionHelperTest.php | 2 +- Tests/Tester/ApplicationTesterTest.php | 12 +++-- Tests/Tester/CommandTesterTest.php | 36 +++++++++++---- .../phpt/uses_stdin_as_interactive_input.phpt | 4 +- composer.json | 3 +- 39 files changed, 221 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0ee13c44..6497def0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ CHANGELOG * Deprecate methods `Command::getDefaultName()` and `Command::getDefaultDescription()` in favor of the `#[AsCommand]` attribute * Add support for Markdown format in `Table` * Add support for `LockableTrait` in invokable commands + * Deprecate returning a non-integer value from a `\Closure` function set via `Command::setCode()` 7.2 --- diff --git a/Command/Command.php b/Command/Command.php index 5e30eb5fa..f79475d56 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -347,7 +347,7 @@ public function complete(CompletionInput $input, CompletionSuggestions $suggesti */ public function setCode(callable $code): static { - $this->code = new InvokableCommand($this, $code, triggerDeprecations: true); + $this->code = new InvokableCommand($this, $code); return $this; } diff --git a/Command/InvokableCommand.php b/Command/InvokableCommand.php index 2b3c41501..329d8b253 100644 --- a/Command/InvokableCommand.php +++ b/Command/InvokableCommand.php @@ -32,11 +32,11 @@ class InvokableCommand { private readonly \Closure $code; private readonly \ReflectionFunction $reflection; + private bool $triggerDeprecations = false; public function __construct( private readonly Command $command, callable $code, - private readonly bool $triggerDeprecations = false, ) { $this->code = $this->getClosure($code); $this->reflection = new \ReflectionFunction($this->code); @@ -49,17 +49,17 @@ public function __invoke(InputInterface $input, OutputInterface $output): int { $statusCode = ($this->code)(...$this->getParameters($input, $output)); - if (null !== $statusCode && !\is_int($statusCode)) { + if (!\is_int($statusCode)) { if ($this->triggerDeprecations) { trigger_deprecation('symfony/console', '7.3', \sprintf('Returning a non-integer value from the command "%s" is deprecated and will throw an exception in Symfony 8.0.', $this->command->getName())); return 0; } - throw new LogicException(\sprintf('The command "%s" must return either void or an integer value in the "%s" method, but "%s" was returned.', $this->command->getName(), $this->reflection->getName(), get_debug_type($statusCode))); + throw new \TypeError(\sprintf('The command "%s" must return an integer value in the "%s" method, but "%s" was returned.', $this->command->getName(), $this->reflection->getName(), get_debug_type($statusCode))); } - return $statusCode ?? 0; + return $statusCode; } /** @@ -85,6 +85,8 @@ private function getClosure(callable $code): \Closure return $code(...); } + $this->triggerDeprecations = true; + if (null !== (new \ReflectionFunction($code))->getClosureThis()) { return $code; } diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 835195743..c5c796517 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -196,8 +196,10 @@ public function testRegister() public function testRegisterAmbiguous() { - $code = function (InputInterface $input, OutputInterface $output) { + $code = function (InputInterface $input, OutputInterface $output): int { $output->writeln('It works!'); + + return 0; }; $application = new Application(); @@ -1275,7 +1277,9 @@ public function testAddingOptionWithDuplicateShortcut() ->register('foo') ->setAliases(['f']) ->setDefinition([new InputOption('survey', 'e', InputOption::VALUE_REQUIRED, 'My option with a shortcut.')]) - ->setCode(function (InputInterface $input, OutputInterface $output) {}) + ->setCode(function (InputInterface $input, OutputInterface $output): int { + return 0; + }) ; $input = new ArrayInput(['command' => 'foo']); @@ -1298,7 +1302,9 @@ public function testAddingAlreadySetDefinitionElementData($def) $application ->register('foo') ->setDefinition([$def]) - ->setCode(function (InputInterface $input, OutputInterface $output) {}) + ->setCode(function (InputInterface $input, OutputInterface $output): int { + return 0; + }) ; $input = new ArrayInput(['command' => 'foo']); @@ -1435,8 +1441,10 @@ public function testRunWithDispatcher() $application->setAutoExit(false); $application->setDispatcher($this->getDispatcher()); - $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output): int { $output->write('foo.'); + + return 0; }); $tester = new ApplicationTester($application); @@ -1491,8 +1499,10 @@ public function testRunDispatchesAllEventsWithExceptionInListener() $application->setDispatcher($dispatcher); $application->setAutoExit(false); - $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output): int { $output->write('foo.'); + + return 0; }); $tester = new ApplicationTester($application); @@ -1559,8 +1569,10 @@ public function testRunAllowsErrorListenersToSilenceTheException() $application->setDispatcher($dispatcher); $application->setAutoExit(false); - $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output): int { $output->write('foo.'); + + return 0; }); $tester = new ApplicationTester($application); @@ -1671,8 +1683,10 @@ public function testRunWithDispatcherSkippingCommand() $application->setDispatcher($this->getDispatcher(true)); $application->setAutoExit(false); - $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output): int { $output->write('foo.'); + + return 0; }); $tester = new ApplicationTester($application); @@ -1698,8 +1712,10 @@ public function testRunWithDispatcherAccessingInputOptions() $application->setDispatcher($dispatcher); $application->setAutoExit(false); - $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output): int { $output->write('foo.'); + + return 0; }); $tester = new ApplicationTester($application); @@ -1728,8 +1744,10 @@ public function testRunWithDispatcherAddingInputOptions() $application->setDispatcher($dispatcher); $application->setAutoExit(false); - $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output): int { $output->write('foo.'); + + return 0; }); $tester = new ApplicationTester($application); @@ -1858,12 +1876,12 @@ public function testFindAlternativesDoesNotLoadSameNamespaceCommandsOnExactMatch 'foo:bar' => function () use (&$loaded) { $loaded['foo:bar'] = true; - return (new Command('foo:bar'))->setCode(function () {}); + return (new Command('foo:bar'))->setCode(function (): int { return 0; }); }, 'foo' => function () use (&$loaded) { $loaded['foo'] = true; - return (new Command('foo'))->setCode(function () {}); + return (new Command('foo'))->setCode(function (): int { return 0; }); }, ])); @@ -1934,8 +1952,10 @@ public function testThrowingErrorListener() $application->setAutoExit(false); $application->setCatchExceptions(false); - $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output): int { $output->write('foo.'); + + return 0; }); $tester = new ApplicationTester($application); diff --git a/Tests/Command/CommandTest.php b/Tests/Command/CommandTest.php index e417b0656..64d32b2cb 100644 --- a/Tests/Command/CommandTest.php +++ b/Tests/Command/CommandTest.php @@ -18,6 +18,7 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Helper\FormatterHelper; +use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; @@ -350,8 +351,10 @@ public function testRunWithProcessTitle() public function testSetCode() { $command = new \TestCommand(); - $ret = $command->setCode(function (InputInterface $input, OutputInterface $output) { + $ret = $command->setCode(function (InputInterface $input, OutputInterface $output): int { $output->writeln('from the code...'); + + return 0; }); $this->assertEquals($command, $ret, '->setCode() implements a fluent interface'); $tester = new CommandTester($command); @@ -396,8 +399,10 @@ public function testSetCodeWithStaticClosure() private static function createClosure() { - return function (InputInterface $input, OutputInterface $output) { + return function (InputInterface $input, OutputInterface $output): int { $output->writeln(isset($this) ? 'bound' : 'not bound'); + + return 0; }; } @@ -411,16 +416,20 @@ public function testSetCodeWithNonClosureCallable() $this->assertEquals('interact called'.\PHP_EOL.'from the code...'.\PHP_EOL, $tester->getDisplay()); } - public function callableMethodCommand(InputInterface $input, OutputInterface $output) + public function callableMethodCommand(InputInterface $input, OutputInterface $output): int { $output->writeln('from the code...'); + + return 0; } public function testSetCodeWithStaticAnonymousFunction() { $command = new \TestCommand(); - $command->setCode(static function (InputInterface $input, OutputInterface $output) { + $command->setCode(static function (InputInterface $input, OutputInterface $output): int { $output->writeln(isset($this) ? 'bound' : 'not bound'); + + return 0; }); $tester = new CommandTester($command); $tester->execute([]); @@ -495,14 +504,28 @@ public function testDeprecatedMethods() new FooCommand(); } + + /** + * @group legacy + */ + public function testDeprecatedNonIntegerReturnTypeFromClosureCode() + { + $this->expectDeprecation('Since symfony/console 7.3: Returning a non-integer value from the command "foo" is deprecated and will throw an exception in Symfony 8.0.'); + + $command = new Command('foo'); + $command->setCode(function () {}); + $command->run(new ArrayInput([]), new NullOutput()); + } } // In order to get an unbound closure, we should create it outside a class // scope. function createClosure() { - return function (InputInterface $input, OutputInterface $output) { + return function (InputInterface $input, OutputInterface $output): int { $output->writeln($this instanceof Command ? 'bound to the command' : 'not bound to the command'); + + return 0; }; } diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index 3633c8659..b0a337fb0 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -34,7 +34,9 @@ public function testCommandInputArgumentDefinition() #[Argument] string $lastName = '', #[Argument(description: 'Short argument description')] string $bio = '', #[Argument(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], - ) {}); + ): int { + return 0; + }); $nameInputArgument = $command->getDefinition()->getArgument('first-name'); self::assertSame('first-name', $nameInputArgument->getName()); @@ -75,7 +77,9 @@ public function testCommandInputOptionDefinition() #[Option(shortcut: 'v')] bool $verbose = false, #[Option(description: 'User groups')] array $groups = [], #[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], - ) {}); + ): int { + return 0; + }); $timeoutInputOption = $command->getDefinition()->getOption('idle'); self::assertSame('idle', $timeoutInputOption->getName()); @@ -138,6 +142,19 @@ public function testInvalidOptionType() $command->getDefinition(); } + public function testInvalidReturnType() + { + $command = new Command('foo'); + $command->setCode(new class { + public function __invoke() {} + }); + + $this->expectException(\TypeError::class); + $this->expectExceptionMessage('The command "foo" must return an integer value in the "__invoke" method, but "null" was returned.'); + + $command->run(new ArrayInput([]), new NullOutput()); + } + /** * @dataProvider provideInputArguments */ @@ -149,11 +166,13 @@ public function testInputArguments(array $parameters, array $expected) #[Argument] ?string $b, #[Argument] string $c = '', #[Argument] array $d = [], - ) use ($expected) { + ) use ($expected): int { $this->assertSame($expected[0], $a); $this->assertSame($expected[1], $b); $this->assertSame($expected[2], $c); $this->assertSame($expected[3], $d); + + return 0; }); $command->run(new ArrayInput($parameters), new NullOutput()); @@ -176,10 +195,12 @@ public function testBinaryInputOptions(array $parameters, array $expected) #[Option] bool $a = true, #[Option] bool $b = false, #[Option] ?bool $c = null, - ) use ($expected) { + ) use ($expected): int { $this->assertSame($expected[0], $a); $this->assertSame($expected[1], $b); $this->assertSame($expected[2], $c); + + return 0; }); $command->run(new ArrayInput($parameters), new NullOutput()); @@ -202,10 +223,12 @@ public function testNonBinaryInputOptions(array $parameters, array $expected) #[Option] ?string $a = null, #[Option] ?string $b = 'b', #[Option] ?array $c = [], - ) use ($expected) { + ) use ($expected): int { $this->assertSame($expected[0], $a); $this->assertSame($expected[1], $b); $this->assertSame($expected[2], $c); + + return 0; }); $command->run(new ArrayInput($parameters), new NullOutput()); diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_0.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_0.php index 8fe7c0771..86095576c 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_0.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_0.php @@ -5,7 +5,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure has single blank line at start when using block element -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->caution('Lorem ipsum dolor sit amet'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_1.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_1.php index e5c700d60..c72a3b390 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_1.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_1.php @@ -5,9 +5,11 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure has single blank line between titles and blocks -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->title('Title'); $output->warning('Lorem ipsum dolor sit amet'); $output->title('Title'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_10.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_10.php index 3111873dd..c9bc1e30a 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_10.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_10.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure that all lines are aligned to the begin of the first line in a very long line block -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->block( 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', @@ -14,4 +14,6 @@ 'X ', true ); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_11.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_11.php index 3ed897def..838b66707 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_11.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_11.php @@ -5,8 +5,10 @@ use Symfony\Component\Console\Style\SymfonyStyle; // ensure long words are properly wrapped in blocks -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $word = 'Lopadotemachoselachogaleokranioleipsanodrimhypotrimmatosilphioparaomelitokatakechymenokichlepikossyphophattoperisteralektryonoptekephalliokigklopeleiolagoiosiraiobaphetraganopterygon'; $sfStyle = new SymfonyStyle($input, $output); $sfStyle->block($word, 'CUSTOM', 'fg=white;bg=blue', ' § ', false); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_12.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_12.php index 8c458ae76..24d64df8d 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_12.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_12.php @@ -5,9 +5,11 @@ use Symfony\Component\Console\Style\SymfonyStyle; // ensure that all lines are aligned to the begin of the first one and start with '//' in a very long line comment -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->comment( 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum' ); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_13.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_13.php index 9bcc68f69..4d0799770 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_13.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_13.php @@ -5,10 +5,12 @@ use Symfony\Component\Console\Style\SymfonyStyle; // ensure that nested tags have no effect on the color of the '//' prefix -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output->setDecorated(true); $output = new SymfonyStyle($input, $output); $output->comment( 'Árvíztűrőtükörfúrógép 🎼 Lorem ipsum dolor sit 💕 amet, consectetur adipisicing elit, sed do eiusmod tempor incididu labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum' ); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_14.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_14.php index a893a48bf..b079e4c5d 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_14.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_14.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; // ensure that block() behaves properly with a prefix and without type -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->block( 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', @@ -14,4 +14,6 @@ '$ ', true ); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_15.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_15.php index 68402cd40..664a1938b 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_15.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_15.php @@ -5,10 +5,12 @@ use Symfony\Component\Console\Style\SymfonyStyle; // ensure that block() behaves properly with a type and without prefix -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->block( 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', 'TEST' ); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_16.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_16.php index 66e817963..2b7bba059 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_16.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_16.php @@ -5,11 +5,13 @@ use Symfony\Component\Console\Style\SymfonyStyle; // ensure that block() output is properly formatted (even padding lines) -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output->setDecorated(true); $output = new SymfonyStyle($input, $output); $output->success( 'Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum', 'TEST' ); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_17.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_17.php index 311e6b392..399a5a06f 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_17.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_17.php @@ -5,9 +5,11 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure symfony style helper methods handle trailing backslashes properly when decorating user texts -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->title('Title ending with \\'); $output->section('Section ending with \\'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_18.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_18.php index d4afa45cf..383615a34 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_18.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_18.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->definitionList( @@ -15,4 +15,6 @@ new TableSeparator(), ['foo2' => 'bar2'] ); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_19.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_19.php index e25a7ef29..3e57f66ca 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_19.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_19.php @@ -5,7 +5,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure formatting tables when using multiple headers with TableCell -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->horizontalTable(['a', 'b', 'c', 'd'], [[1, 2, 3], [4, 5], [7, 8, 9]]); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_2.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_2.php index a16ad505d..5bba34f36 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_2.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_2.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure has single blank line between blocks -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->warning('Warning'); $output->caution('Caution'); @@ -14,4 +14,6 @@ $output->note('Note'); $output->info('Info'); $output->block('Custom block', 'CUSTOM', 'fg=white;bg=green', 'X ', true); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_20.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_20.php index 6b47969ee..3bdd5d5cf 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_20.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_20.php @@ -5,9 +5,11 @@ use Symfony\Component\Console\Style\SymfonyStyle; // Ensure that closing tag is applied once -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output->setDecorated(true); $output = new SymfonyStyle($input, $output); $output->write('do you want something'); $output->writeln('?'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_21.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_21.php index 8460e7ece..3faf7c7a0 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_21.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_21.php @@ -5,9 +5,11 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure texts with emojis don't make longer lines than expected -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->success('Lorem ipsum dolor sit amet'); $output->success('Lorem ipsum dolor sit amet with one emoji 🎉'); $output->success('Lorem ipsum dolor sit amet with so many of them 👩‍🌾👩‍🌾👩‍🌾👩‍🌾👩‍🌾'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_22.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_22.php index 1070394a8..3ec61081b 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_22.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_22.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; // ensure that nested tags have no effect on the color of the '//' prefix -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output->setDecorated(true); $output = new SymfonyStyle($input, $output); $output->block( @@ -16,4 +16,6 @@ false, false ); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_23.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_23.php index e6228fe0b..618de55ce 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_23.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_23.php @@ -4,7 +4,9 @@ use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->text('Hello'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_3.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_3.php index 99253a6c0..b6a3cd27c 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_3.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_3.php @@ -5,8 +5,10 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure has single blank line between two titles -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->title('First title'); $output->title('Second title'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_4.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_4.php index b2f3d9954..d196735c1 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_4.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_4.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure has single blank line after any text and a title -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->write('Lorem ipsum dolor sit amet'); @@ -31,4 +31,6 @@ $output->writeln('Lorem ipsum dolor sit amet'); $output->newLine(2); //Should append an extra blank line $output->title('Fifth title'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_4_with_iterators.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_4_with_iterators.php index 3b215c7f2..24de2cab3 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_4_with_iterators.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_4_with_iterators.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure has single blank line after any text and a title -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->write('Lorem ipsum dolor sit amet'); @@ -31,4 +31,6 @@ $output->writeln('Lorem ipsum dolor sit amet'); $output->newLine(2); //Should append an extra blank line $output->title('Fifth title'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_5.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_5.php index 6fba5737f..6fab68233 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_5.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_5.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure has proper line ending before outputting a text block like with SymfonyStyle::listing() or SymfonyStyle::text() -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->writeln('Lorem ipsum dolor sit amet'); @@ -34,4 +34,6 @@ 'Lorem ipsum dolor sit amet', 'consectetur adipiscing elit', ]); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_6.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_6.php index 3278f6ea0..cef96d5d9 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_6.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_6.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure has proper blank line after text block when using a block like with SymfonyStyle::success -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->listing([ @@ -13,4 +13,6 @@ 'consectetur adipiscing elit', ]); $output->success('Lorem ipsum dolor sit amet'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_7.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_7.php index 037c6ab6b..f4f673c17 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_7.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_7.php @@ -5,11 +5,13 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure questions do not output anything when input is non-interactive -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->title('Title'); $output->askHidden('Hidden question'); $output->choice('Choice question with default', ['choice1', 'choice2'], 'choice1'); $output->confirm('Confirmation with yes default', true); $output->text('Duis aute irure dolor in reprehenderit in voluptate velit esse'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_8.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_8.php index fe9d484d2..856665451 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_8.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_8.php @@ -6,7 +6,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure formatting tables when using multiple headers with TableCell -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $headers = [ [new TableCell('Main table title', ['colspan' => 3])], ['ISBN', 'Title', 'Author'], @@ -23,4 +23,6 @@ $output = new SymfonyStyle($input, $output); $output->table($headers, $rows); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/command_9.php b/Tests/Fixtures/Style/SymfonyStyle/command/command_9.php index 73af4ae1e..77dd8d087 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/command_9.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/command_9.php @@ -5,7 +5,9 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure that all lines are aligned to the begin of the first line in a multi-line block -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $output->block(['Custom block', 'Second custom block line'], 'CUSTOM', 'fg=white;bg=green', 'X ', true); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/command/interactive_command_1.php b/Tests/Fixtures/Style/SymfonyStyle/command/interactive_command_1.php index 3c9c74405..7855f9dcd 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/command/interactive_command_1.php +++ b/Tests/Fixtures/Style/SymfonyStyle/command/interactive_command_1.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; //Ensure that questions have the expected outputs -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $output = new SymfonyStyle($input, $output); $stream = fopen('php://memory', 'r+', false); @@ -16,4 +16,6 @@ $output->ask('What\'s your name?'); $output->ask('How are you?'); $output->ask('Where do you come from?'); + + return 0; }; diff --git a/Tests/Fixtures/Style/SymfonyStyle/progress/command_progress_iterate.php b/Tests/Fixtures/Style/SymfonyStyle/progress/command_progress_iterate.php index 6487bc3b1..3744c9b22 100644 --- a/Tests/Fixtures/Style/SymfonyStyle/progress/command_progress_iterate.php +++ b/Tests/Fixtures/Style/SymfonyStyle/progress/command_progress_iterate.php @@ -5,7 +5,7 @@ use Symfony\Component\Console\Style\SymfonyStyle; // progressIterate -return function (InputInterface $input, OutputInterface $output) { +return function (InputInterface $input, OutputInterface $output): int { $style = new SymfonyStyle($input, $output); foreach ($style->progressIterate(\range(1, 10)) as $step) { @@ -13,4 +13,6 @@ } $style->writeln('end of progressbar'); + + return 0; }; diff --git a/Tests/Fixtures/application_signalable.php b/Tests/Fixtures/application_signalable.php index 978406637..c737ba1bf 100644 --- a/Tests/Fixtures/application_signalable.php +++ b/Tests/Fixtures/application_signalable.php @@ -23,7 +23,7 @@ public function handleSignal(int $signal, int|false $previousExitCode = 0): int| exit(0); } }) - ->setCode(function(InputInterface $input, OutputInterface $output) { + ->setCode(function(InputInterface $input, OutputInterface $output): int { $this->getHelper('question') ->ask($input, $output, new ChoiceQuestion('😊', ['y'])); diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index dbbf66e02..0e91dd85b 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -777,7 +777,7 @@ public function testQuestionValidatorRepeatsThePrompt() $application = new Application(); $application->setAutoExit(false); $application->register('question') - ->setCode(function (InputInterface $input, OutputInterface $output) use (&$tries) { + ->setCode(function (InputInterface $input, OutputInterface $output) use (&$tries): int { $question = new Question('This is a promptable question'); $question->setValidator(function ($value) use (&$tries) { ++$tries; diff --git a/Tests/Tester/ApplicationTesterTest.php b/Tests/Tester/ApplicationTesterTest.php index f990e94cc..843f2eac7 100644 --- a/Tests/Tester/ApplicationTesterTest.php +++ b/Tests/Tester/ApplicationTesterTest.php @@ -31,8 +31,10 @@ protected function setUp(): void $this->application->setAutoExit(false); $this->application->register('foo') ->addArgument('foo') - ->setCode(function (OutputInterface $output) { + ->setCode(function (OutputInterface $output): int { $output->writeln('foo'); + + return 0; }) ; @@ -67,11 +69,13 @@ public function testSetInputs() { $application = new Application(); $application->setAutoExit(false); - $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output) { + $application->register('foo')->setCode(function (InputInterface $input, OutputInterface $output): int { $helper = new QuestionHelper(); $helper->ask($input, $output, new Question('Q1')); $helper->ask($input, $output, new Question('Q2')); $helper->ask($input, $output, new Question('Q3')); + + return 0; }); $tester = new ApplicationTester($application); @@ -93,8 +97,10 @@ public function testErrorOutput() $application->setAutoExit(false); $application->register('foo') ->addArgument('foo') - ->setCode(function (OutputInterface $output) { + ->setCode(function (OutputInterface $output): int { $output->getErrorOutput()->write('foo'); + + return 0; }) ; diff --git a/Tests/Tester/CommandTesterTest.php b/Tests/Tester/CommandTesterTest.php index 2e5329f84..cfdebe4d8 100644 --- a/Tests/Tester/CommandTesterTest.php +++ b/Tests/Tester/CommandTesterTest.php @@ -34,7 +34,11 @@ protected function setUp(): void $this->command = new Command('foo'); $this->command->addArgument('command'); $this->command->addArgument('foo'); - $this->command->setCode(function (OutputInterface $output) { $output->writeln('foo'); }); + $this->command->setCode(function (OutputInterface $output): int { + $output->writeln('foo'); + + return 0; + }); $this->tester = new CommandTester($this->command); $this->tester->execute(['foo' => 'bar'], ['interactive' => false, 'decorated' => false, 'verbosity' => Output::VERBOSITY_VERBOSE]); @@ -94,7 +98,11 @@ public function testCommandFromApplication() $application->setAutoExit(false); $command = new Command('foo'); - $command->setCode(function (OutputInterface $output) { $output->writeln('foo'); }); + $command->setCode(function (OutputInterface $output): int { + $output->writeln('foo'); + + return 0; + }); $application->add($command); @@ -114,11 +122,13 @@ public function testCommandWithInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command): int { $helper = $command->getHelper('question'); $helper->ask($input, $output, new Question($questions[0])); $helper->ask($input, $output, new Question($questions[1])); $helper->ask($input, $output, new Question($questions[2])); + + return 0; }); $tester = new CommandTester($command); @@ -139,11 +149,13 @@ public function testCommandWithDefaultInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command): int { $helper = $command->getHelper('question'); $helper->ask($input, $output, new Question($questions[0], 'Bobby')); $helper->ask($input, $output, new Question($questions[1], 'Fine')); $helper->ask($input, $output, new Question($questions[2], 'France')); + + return 0; }); $tester = new CommandTester($command); @@ -164,12 +176,14 @@ public function testCommandWithWrongInputsNumber() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command): int { $helper = $command->getHelper('question'); $helper->ask($input, $output, new ChoiceQuestion('choice', ['a', 'b'])); $helper->ask($input, $output, new Question($questions[0])); $helper->ask($input, $output, new Question($questions[1])); $helper->ask($input, $output, new Question($questions[2])); + + return 0; }); $tester = new CommandTester($command); @@ -191,12 +205,14 @@ public function testCommandWithQuestionsButNoInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); - $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions, $command): int { $helper = $command->getHelper('question'); $helper->ask($input, $output, new ChoiceQuestion('choice', ['a', 'b'])); $helper->ask($input, $output, new Question($questions[0])); $helper->ask($input, $output, new Question($questions[1])); $helper->ask($input, $output, new Question($questions[2])); + + return 0; }); $tester = new CommandTester($command); @@ -216,11 +232,13 @@ public function testSymfonyStyleCommandWithInputs() ]; $command = new Command('foo'); - $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions) { + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($questions): int { $io = new SymfonyStyle($input, $output); $io->ask($questions[0]); $io->ask($questions[1]); $io->ask($questions[2]); + + return 0; }); $tester = new CommandTester($command); @@ -235,8 +253,10 @@ public function testErrorOutput() $command = new Command('foo'); $command->addArgument('command'); $command->addArgument('foo'); - $command->setCode(function (OutputInterface $output) { + $command->setCode(function (OutputInterface $output): int { $output->getErrorOutput()->write('foo'); + + return 0; }); $tester = new CommandTester($command); diff --git a/Tests/phpt/uses_stdin_as_interactive_input.phpt b/Tests/phpt/uses_stdin_as_interactive_input.phpt index 3f329cc73..fedb64b61 100644 --- a/Tests/phpt/uses_stdin_as_interactive_input.phpt +++ b/Tests/phpt/uses_stdin_as_interactive_input.phpt @@ -17,9 +17,11 @@ require $vendor.'/vendor/autoload.php'; (new Application()) ->register('app') - ->setCode(function(InputInterface $input, OutputInterface $output) { + ->setCode(function(InputInterface $input, OutputInterface $output): int { $output->writeln((new QuestionHelper())->ask($input, $output, new Question('Foo?', 'foo'))); $output->writeln((new QuestionHelper())->ask($input, $output, new Question('Bar?', 'bar'))); + + return 0; }) ->getApplication() ->setDefaultCommand('app', true) diff --git a/composer.json b/composer.json index 083036d5c..6247ee94e 100644 --- a/composer.json +++ b/composer.json @@ -43,7 +43,8 @@ "symfony/dotenv": "<6.4", "symfony/event-dispatcher": "<6.4", "symfony/lock": "<6.4", - "symfony/process": "<6.4" + "symfony/process": "<6.4", + "symfony/runtime": "<7.3" }, "autoload": { "psr-4": { "Symfony\\Component\\Console\\": "" }, From a16e315188815c12c73f40e235119efea80a2d5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Karel=20Syrov=C3=BD?= Date: Fri, 28 Mar 2025 02:05:16 +0100 Subject: [PATCH 20/45] [Console] Mark `AsCommand` attribute as `@final` --- Attribute/AsCommand.php | 2 ++ CHANGELOG.md | 1 + 2 files changed, 3 insertions(+) diff --git a/Attribute/AsCommand.php b/Attribute/AsCommand.php index 2147e7151..767d46ebb 100644 --- a/Attribute/AsCommand.php +++ b/Attribute/AsCommand.php @@ -13,6 +13,8 @@ /** * Service tag to autoconfigure commands. + * + * @final since Symfony 7.3 */ #[\Attribute(\Attribute::TARGET_CLASS)] class AsCommand diff --git a/CHANGELOG.md b/CHANGELOG.md index 6497def0f..b84099a1d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ CHANGELOG * Add support for Markdown format in `Table` * 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` 7.2 --- From fb9eba1d9ecf04a5c8945f9f12e9e37d0041dcdb Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Fri, 4 Apr 2025 15:02:15 +0200 Subject: [PATCH 21/45] replace expectDeprecation() with expectUserDeprecationMessage() --- Tests/Command/CommandTest.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Tests/Command/CommandTest.php b/Tests/Command/CommandTest.php index 64d32b2cb..0db3572fc 100644 --- a/Tests/Command/CommandTest.php +++ b/Tests/Command/CommandTest.php @@ -12,7 +12,7 @@ namespace Symfony\Component\Console\Tests\Command; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; @@ -30,7 +30,7 @@ class CommandTest extends TestCase { - use ExpectDeprecationTrait; + use ExpectUserDeprecationMessageTrait; protected static string $fixturesPath; @@ -453,8 +453,8 @@ public function testCommandAttribute() */ public function testCommandAttributeWithDeprecatedMethods() { - $this->expectDeprecation('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultName()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); - $this->expectDeprecation('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultDescription()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectUserDeprecationMessage('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultName()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectUserDeprecationMessage('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultDescription()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); $this->assertSame('|foo|f', Php8Command::getDefaultName()); $this->assertSame('desc', Php8Command::getDefaultDescription()); @@ -473,8 +473,8 @@ public function testAttributeOverridesProperty() */ public function testAttributeOverridesPropertyWithDeprecatedMethods() { - $this->expectDeprecation('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultName()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); - $this->expectDeprecation('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultDescription()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectUserDeprecationMessage('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultName()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectUserDeprecationMessage('Since symfony/console 7.3: Method "Symfony\Component\Console\Command\Command::getDefaultDescription()" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); $this->assertSame('my:command', MyAnnotatedCommand::getDefaultName()); $this->assertSame('This is a command I wrote all by myself', MyAnnotatedCommand::getDefaultDescription()); @@ -499,8 +499,8 @@ public function testDefaultCommand() */ public function testDeprecatedMethods() { - $this->expectDeprecation('Since symfony/console 7.3: Overriding "Command::getDefaultName()" in "Symfony\Component\Console\Tests\Command\FooCommand" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); - $this->expectDeprecation('Since symfony/console 7.3: Overriding "Command::getDefaultDescription()" in "Symfony\Component\Console\Tests\Command\FooCommand" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectUserDeprecationMessage('Since symfony/console 7.3: Overriding "Command::getDefaultName()" in "Symfony\Component\Console\Tests\Command\FooCommand" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); + $this->expectUserDeprecationMessage('Since symfony/console 7.3: Overriding "Command::getDefaultDescription()" in "Symfony\Component\Console\Tests\Command\FooCommand" is deprecated and will be removed in Symfony 8.0, use the #[AsCommand] attribute instead.'); new FooCommand(); } @@ -510,7 +510,7 @@ public function testDeprecatedMethods() */ public function testDeprecatedNonIntegerReturnTypeFromClosureCode() { - $this->expectDeprecation('Since symfony/console 7.3: Returning a non-integer value from the command "foo" is deprecated and will throw an exception in Symfony 8.0.'); + $this->expectUserDeprecationMessage('Since symfony/console 7.3: Returning a non-integer value from the command "foo" is deprecated and will throw an exception in Symfony 8.0.'); $command = new Command('foo'); $command->setCode(function () {}); From d10b149020a8cec39fad6e34adf1295ffab768f8 Mon Sep 17 00:00:00 2001 From: Robin Chalas Date: Mon, 5 May 2025 01:49:22 +0200 Subject: [PATCH 22/45] [Console] Use kebab-case for auto-guessed input arguments/options names --- Attribute/Argument.php | 3 ++- Attribute/Option.php | 3 ++- Tests/Command/InvokableCommandTest.php | 14 +++++++------- composer.json | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/Attribute/Argument.php b/Attribute/Argument.php index 099d49676..b5e45be3f 100644 --- a/Attribute/Argument.php +++ b/Attribute/Argument.php @@ -16,6 +16,7 @@ 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)] class Argument @@ -65,7 +66,7 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self } if (!$self->name) { - $self->name = $name; + $self->name = (new UnicodeString($name))->kebab(); } $self->default = $parameter->isDefaultValueAvailable() ? $parameter->getDefaultValue() : null; diff --git a/Attribute/Option.php b/Attribute/Option.php index 02002a5ad..a526b6723 100644 --- a/Attribute/Option.php +++ b/Attribute/Option.php @@ -16,6 +16,7 @@ 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)] class Option @@ -73,7 +74,7 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self } if (!$self->name) { - $self->name = $name; + $self->name = (new UnicodeString($name))->kebab(); } $self->default = $parameter->getDefaultValue(); diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index b0a337fb0..65c386345 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -29,7 +29,7 @@ public function testCommandInputArgumentDefinition() { $command = new Command('foo'); $command->setCode(function ( - #[Argument(name: 'first-name')] string $name, + #[Argument(name: 'very-first-name')] string $name, #[Argument] ?string $firstName, #[Argument] string $lastName = '', #[Argument(description: 'Short argument description')] string $bio = '', @@ -38,17 +38,17 @@ public function testCommandInputArgumentDefinition() return 0; }); - $nameInputArgument = $command->getDefinition()->getArgument('first-name'); - self::assertSame('first-name', $nameInputArgument->getName()); + $nameInputArgument = $command->getDefinition()->getArgument('very-first-name'); + self::assertSame('very-first-name', $nameInputArgument->getName()); self::assertTrue($nameInputArgument->isRequired()); - $lastNameInputArgument = $command->getDefinition()->getArgument('firstName'); - self::assertSame('firstName', $lastNameInputArgument->getName()); + $lastNameInputArgument = $command->getDefinition()->getArgument('first-name'); + self::assertSame('first-name', $lastNameInputArgument->getName()); self::assertFalse($lastNameInputArgument->isRequired()); self::assertNull($lastNameInputArgument->getDefault()); - $lastNameInputArgument = $command->getDefinition()->getArgument('lastName'); - self::assertSame('lastName', $lastNameInputArgument->getName()); + $lastNameInputArgument = $command->getDefinition()->getArgument('last-name'); + self::assertSame('last-name', $lastNameInputArgument->getName()); self::assertFalse($lastNameInputArgument->isRequired()); self::assertSame('', $lastNameInputArgument->getDefault()); diff --git a/composer.json b/composer.json index 6247ee94e..b565f86e3 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ "symfony/deprecation-contracts": "^2.5|^3", "symfony/polyfill-mbstring": "~1.0", "symfony/service-contracts": "^2.5|^3", - "symfony/string": "^6.4|^7.0" + "symfony/string": "^7.2" }, "require-dev": { "symfony/config": "^6.4|^7.0", From 5ade3603a00def33dc8a2899e2f92b3b2e4bb44c Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Tue, 6 May 2025 14:12:18 +0200 Subject: [PATCH 23/45] remove conflict rule --- composer.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/composer.json b/composer.json index b565f86e3..65d69913a 100644 --- a/composer.json +++ b/composer.json @@ -43,8 +43,7 @@ "symfony/dotenv": "<6.4", "symfony/event-dispatcher": "<6.4", "symfony/lock": "<6.4", - "symfony/process": "<6.4", - "symfony/runtime": "<7.3" + "symfony/process": "<6.4" }, "autoload": { "psr-4": { "Symfony\\Component\\Console\\": "" }, From fdbc4c32d108abf5d5b69502025b12fdc23f2f81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Tue, 6 May 2025 15:45:12 +0200 Subject: [PATCH 24/45] Ensure overriding Command::execute() keep priority over __invoke --- Command/Command.php | 2 +- Tests/Command/InvokableCommandTest.php | 41 ++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/Command/Command.php b/Command/Command.php index f79475d56..c93340a77 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -134,7 +134,7 @@ public function __construct(?string $name = null) $this->setHelp($attribute?->help ?? ''); } - if (\is_callable($this)) { + if (\is_callable($this) && (new \ReflectionMethod($this, 'execute'))->getDeclaringClass()->name === self::class) { $this->code = new InvokableCommand($this, $this(...)); } diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index 65c386345..d355c44ce 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -21,7 +21,9 @@ use Symfony\Component\Console\Exception\InvalidOptionException; use Symfony\Component\Console\Exception\LogicException; use Symfony\Component\Console\Input\ArrayInput; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\NullOutput; +use Symfony\Component\Console\Output\OutputInterface; class InvokableCommandTest extends TestCase { @@ -142,6 +144,45 @@ public function testInvalidOptionType() $command->getDefinition(); } + public function testExecuteHasPriorityOverInvokeMethod() + { + $command = new class extends Command { + public string $called; + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->called = __FUNCTION__; + + return 0; + } + + public function __invoke(): int + { + $this->called = __FUNCTION__; + + return 0; + } + }; + + $command->run(new ArrayInput([]), new NullOutput()); + $this->assertSame('execute', $command->called); + } + + public function testCallInvokeMethodWhenExtendingCommandClass() + { + $command = new class extends Command { + public string $called; + public function __invoke(): int + { + $this->called = __FUNCTION__; + + return 0; + } + }; + + $command->run(new ArrayInput([]), new NullOutput()); + $this->assertSame('__invoke', $command->called); + } + public function testInvalidReturnType() { $command = new Command('foo'); From 1b5e3e689f83f413a4d594a0d187bcbae71c6c54 Mon Sep 17 00:00:00 2001 From: Antoine Lamirault Date: Tue, 6 May 2025 23:56:38 +0200 Subject: [PATCH 25/45] [Console] Set description as first parameter to Argument and Option attributes --- Attribute/Argument.php | 2 +- Attribute/Option.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Attribute/Argument.php b/Attribute/Argument.php index b5e45be3f..22bfbf48b 100644 --- a/Attribute/Argument.php +++ b/Attribute/Argument.php @@ -35,8 +35,8 @@ class Argument * @param array|callable(CompletionInput):list $suggestedValues The values used for input completion */ public function __construct( - public string $name = '', public string $description = '', + public string $name = '', array|callable $suggestedValues = [], ) { $this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues; diff --git a/Attribute/Option.php b/Attribute/Option.php index a526b6723..099c7d0c2 100644 --- a/Attribute/Option.php +++ b/Attribute/Option.php @@ -38,9 +38,9 @@ class Option * @param array|callable(CompletionInput):list $suggestedValues The values used for input completion */ public function __construct( + public string $description = '', public string $name = '', public array|string|null $shortcut = null, - public string $description = '', array|callable $suggestedValues = [], ) { $this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues; From 01611b66eded3ffc27154a49ca60af2c7901315e Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Thu, 23 Jan 2025 09:53:28 -0500 Subject: [PATCH 26/45] [Console] `#[Option]` rules & restrictions --- Attribute/Option.php | 12 +++++++ Tests/Command/InvokableCommandTest.php | 47 +++++++++++++++++++++----- 2 files changed, 50 insertions(+), 9 deletions(-) diff --git a/Attribute/Option.php b/Attribute/Option.php index 099c7d0c2..4aea4831e 100644 --- a/Attribute/Option.php +++ b/Attribute/Option.php @@ -80,6 +80,18 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self $self->default = $parameter->getDefaultValue(); $self->allowNull = $parameter->allowsNull(); + if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) { + throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable when it has a default boolean value.', $name)); + } + + if ('string' === $self->typeName && null === $self->default) { + throw new LogicException(\sprintf('The option parameter "$%s" must not have a default of null.', $name)); + } + + if ('array' === $self->typeName && $self->allowNull) { + throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable.', $name)); + } + if ('bool' === $self->typeName) { $self->mode = InputOption::VALUE_NONE; if (false !== $self->default) { diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index d355c44ce..88f1b7870 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -261,13 +261,15 @@ public function testNonBinaryInputOptions(array $parameters, array $expected) { $command = new Command('foo'); $command->setCode(function ( - #[Option] ?string $a = null, - #[Option] ?string $b = 'b', - #[Option] ?array $c = [], + #[Option] string $a = '', + #[Option] ?string $b = '', + #[Option] array $c = [], + #[Option] array $d = ['a', 'b'], ) use ($expected): int { $this->assertSame($expected[0], $a); $this->assertSame($expected[1], $b); $this->assertSame($expected[2], $c); + $this->assertSame($expected[3], $d); return 0; }); @@ -277,22 +279,49 @@ public function testNonBinaryInputOptions(array $parameters, array $expected) public static function provideNonBinaryInputOptions(): \Generator { - yield 'defaults' => [[], [null, 'b', []]]; - yield 'with-value' => [['--a' => 'x', '--b' => 'y', '--c' => ['z']], ['x', 'y', ['z']]]; - yield 'without-value' => [['--a' => null, '--b' => null, '--c' => null], [null, null, null]]; + yield 'defaults' => [[], ['', '', [], ['a', 'b']]]; + yield 'with-value' => [['--a' => 'x', '--b' => 'y', '--c' => ['z'], '--d' => ['c', 'd']], ['x', 'y', ['z'], ['c', 'd']]]; + yield 'without-value' => [['--b' => null], ['', null, [], ['a', 'b']]]; } - public function testInvalidOptionDefinition() + /** + * @dataProvider provideInvalidOptionDefinitions + */ + public function testInvalidOptionDefinition(callable $code, string $expectedMessage) { $command = new Command('foo'); - $command->setCode(function (#[Option] string $a) {}); + $command->setCode($code); $this->expectException(LogicException::class); - $this->expectExceptionMessage('The option parameter "$a" must declare a default value.'); + $this->expectExceptionMessage($expectedMessage); $command->getDefinition(); } + public static function provideInvalidOptionDefinitions(): \Generator + { + yield 'no-default' => [ + function (#[Option] string $a) {}, + 'The option parameter "$a" must declare a default value.', + ]; + yield 'nullable-bool-default-true' => [ + function (#[Option] ?bool $a = true) {}, + 'The option parameter "$a" must not be nullable when it has a default boolean value.', + ]; + yield 'nullable-bool-default-false' => [ + function (#[Option] ?bool $a = false) {}, + 'The option parameter "$a" must not be nullable when it has a default boolean value.', + ]; + yield 'nullable-string' => [ + function (#[Option] ?string $a = null) {}, + 'The option parameter "$a" must not have a default of null.', + ]; + yield 'nullable-array' => [ + function (#[Option] ?array $a = null) {}, + 'The option parameter "$a" must not be nullable.', + ]; + } + public function testInvalidRequiredValueOptionEvenWithDefault() { $command = new Command('foo'); From ce1f3d9e59d92cc53d853e5ef7e6184b5de65189 Mon Sep 17 00:00:00 2001 From: HypeMC Date: Fri, 9 May 2025 13:44:27 +0200 Subject: [PATCH 27/45] [Console] Add support for `SignalableCommandInterface` with invokable commands --- Application.php | 4 +- CHANGELOG.md | 1 + Command/Command.php | 12 ++- Command/InvokableCommand.php | 14 +++- Command/TraceableCommand.php | 8 +- Tests/ApplicationTest.php | 74 ++++++++++++++++++- .../AddConsoleCommandPassTest.php | 40 ++++++++++ Tests/Fixtures/application_signalable.php | 3 +- Tests/phpt/alarm/command_exit.phpt | 3 +- Tests/phpt/signal/command_exit.phpt | 3 +- 10 files changed, 141 insertions(+), 21 deletions(-) diff --git a/Application.php b/Application.php index 78d885d25..b4539fa1e 100644 --- a/Application.php +++ b/Application.php @@ -17,7 +17,6 @@ use Symfony\Component\Console\Command\HelpCommand; use Symfony\Component\Console\Command\LazyCommand; use Symfony\Component\Console\Command\ListCommand; -use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\CommandLoader\CommandLoaderInterface; use Symfony\Component\Console\Completion\CompletionInput; use Symfony\Component\Console\Completion\CompletionSuggestions; @@ -1005,8 +1004,7 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI } } - $commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : []; - if ($commandSignals || $this->dispatcher && $this->signalsToDispatchEvent) { + if (($commandSignals = $command->getSubscribedSignals()) || $this->dispatcher && $this->signalsToDispatchEvent) { $signalRegistry = $this->getSignalRegistry(); if (Terminal::hasSttyAvailable()) { diff --git a/CHANGELOG.md b/CHANGELOG.md index b84099a1d..9f3ae3d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ 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 support for `SignalableCommandInterface` with invokable commands 7.2 --- diff --git a/Command/Command.php b/Command/Command.php index c93340a77..f6cd84997 100644 --- a/Command/Command.php +++ b/Command/Command.php @@ -32,7 +32,7 @@ * * @author Fabien Potencier */ -class Command +class Command implements SignalableCommandInterface { // see https://tldp.org/LDP/abs/html/exitcodes.html public const SUCCESS = 0; @@ -674,6 +674,16 @@ public function getHelper(string $name): HelperInterface return $this->helperSet->get($name); } + public function getSubscribedSignals(): array + { + return $this->code?->getSubscribedSignals() ?? []; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + return $this->code?->handleSignal($signal, $previousExitCode) ?? false; + } + /** * Validates a command name. * diff --git a/Command/InvokableCommand.php b/Command/InvokableCommand.php index 329d8b253..72ff407c8 100644 --- a/Command/InvokableCommand.php +++ b/Command/InvokableCommand.php @@ -28,9 +28,10 @@ * * @internal */ -class InvokableCommand +class InvokableCommand implements SignalableCommandInterface { private readonly \Closure $code; + private readonly ?SignalableCommandInterface $signalableCommand; private readonly \ReflectionFunction $reflection; private bool $triggerDeprecations = false; @@ -39,6 +40,7 @@ public function __construct( callable $code, ) { $this->code = $this->getClosure($code); + $this->signalableCommand = $code instanceof SignalableCommandInterface ? $code : null; $this->reflection = new \ReflectionFunction($this->code); } @@ -142,4 +144,14 @@ private function getParameters(InputInterface $input, OutputInterface $output): return $parameters ?: [$input, $output]; } + + public function getSubscribedSignals(): array + { + return $this->signalableCommand?->getSubscribedSignals() ?? []; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + return $this->signalableCommand?->handleSignal($signal, $previousExitCode) ?? false; + } } diff --git a/Command/TraceableCommand.php b/Command/TraceableCommand.php index 659798e65..315f385de 100644 --- a/Command/TraceableCommand.php +++ b/Command/TraceableCommand.php @@ -27,7 +27,7 @@ * * @author Jules Pietri */ -final class TraceableCommand extends Command implements SignalableCommandInterface +final class TraceableCommand extends Command { public readonly Command $command; public int $exitCode; @@ -89,15 +89,11 @@ public function __call(string $name, array $arguments): mixed public function getSubscribedSignals(): array { - return $this->command instanceof SignalableCommandInterface ? $this->command->getSubscribedSignals() : []; + return $this->command->getSubscribedSignals(); } public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false { - if (!$this->command instanceof SignalableCommandInterface) { - return false; - } - $event = $this->stopwatch->start($this->getName().'.handle_signal'); $exit = $this->command->handleSignal($signal, $previousExitCode); diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index c5c796517..268f8ba50 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -2255,6 +2255,41 @@ public function testSignalableRestoresStty() $this->assertSame($previousSttyMode, $sttyMode); } + /** + * @requires extension pcntl + */ + public function testSignalableInvokableCommand() + { + $command = new Command(); + $command->setName('signal-invokable'); + $command->setCode($invokable = new class implements SignalableCommandInterface { + use SignalableInvokableCommandTrait; + }); + + $application = $this->createSignalableApplication($command, null); + $application->setSignalsToDispatchEvent(\SIGUSR1); + + $this->assertSame(1, $application->run(new ArrayInput(['signal-invokable']))); + $this->assertTrue($invokable->signaled); + } + + /** + * @requires extension pcntl + */ + public function testSignalableInvokableCommandThatExtendsBaseCommand() + { + $command = new class extends Command implements SignalableCommandInterface { + use SignalableInvokableCommandTrait; + }; + $command->setName('signal-invokable'); + + $application = $this->createSignalableApplication($command, null); + $application->setSignalsToDispatchEvent(\SIGUSR1); + + $this->assertSame(1, $application->run(new ArrayInput(['signal-invokable']))); + $this->assertTrue($command->signaled); + } + /** * @requires extension pcntl */ @@ -2514,7 +2549,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } #[AsCommand(name: 'signal')] -class SignableCommand extends BaseSignableCommand implements SignalableCommandInterface +class SignableCommand extends BaseSignableCommand { public function getSubscribedSignals(): array { @@ -2531,7 +2566,7 @@ public function handleSignal(int $signal, int|false $previousExitCode = 0): int| } #[AsCommand(name: 'signal')] -class TerminatableCommand extends BaseSignableCommand implements SignalableCommandInterface +class TerminatableCommand extends BaseSignableCommand { public function getSubscribedSignals(): array { @@ -2548,7 +2583,7 @@ public function handleSignal(int $signal, int|false $previousExitCode = 0): int| } #[AsCommand(name: 'signal')] -class TerminatableWithEventCommand extends Command implements SignalableCommandInterface, EventSubscriberInterface +class TerminatableWithEventCommand extends Command implements EventSubscriberInterface { private bool $shouldContinue = true; private OutputInterface $output; @@ -2615,8 +2650,39 @@ public static function getSubscribedEvents(): array } } +trait SignalableInvokableCommandTrait +{ + public bool $signaled = false; + + public function __invoke(): int + { + posix_kill(posix_getpid(), \SIGUSR1); + + for ($i = 0; $i < 1000; ++$i) { + usleep(100); + if ($this->signaled) { + return 1; + } + } + + return 0; + } + + public function getSubscribedSignals(): array + { + return SignalRegistry::isSupported() ? [\SIGUSR1] : []; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + $this->signaled = true; + + return false; + } +} + #[AsCommand(name: 'alarm')] -class AlarmableCommand extends BaseSignableCommand implements SignalableCommandInterface +class AlarmableCommand extends BaseSignableCommand { public function __construct(private int $alarmInterval) { diff --git a/Tests/DependencyInjection/AddConsoleCommandPassTest.php b/Tests/DependencyInjection/AddConsoleCommandPassTest.php index 8a0c1e6b2..9ac660100 100644 --- a/Tests/DependencyInjection/AddConsoleCommandPassTest.php +++ b/Tests/DependencyInjection/AddConsoleCommandPassTest.php @@ -15,6 +15,7 @@ use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Command\LazyCommand; +use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\CommandLoader\ContainerCommandLoader; use Symfony\Component\Console\DependencyInjection\AddConsoleCommandPass; use Symfony\Component\DependencyInjection\Argument\ServiceClosureArgument; @@ -325,6 +326,27 @@ public function testProcessInvokableCommand() self::assertSame('The command description', $command->getDescription()); self::assertSame('The %command.name% command help content.', $command->getHelp()); } + + public function testProcessInvokableSignalableCommand() + { + $container = new ContainerBuilder(); + $container->addCompilerPass(new AddConsoleCommandPass(), PassConfig::TYPE_BEFORE_REMOVING); + + $definition = new Definition(InvokableSignalableCommand::class); + $definition->addTag('console.command', [ + 'command' => 'invokable-signalable', + 'description' => 'The command description', + 'help' => 'The %command.name% command help content.', + ]); + $container->setDefinition('invokable_signalable_command', $definition); + + $container->compile(); + $command = $container->get('console.command_loader')->get('invokable-signalable'); + + self::assertTrue($container->has('invokable_signalable_command.command')); + self::assertSame('The command description', $command->getDescription()); + self::assertSame('The %command.name% command help content.', $command->getHelp()); + } } class MyCommand extends Command @@ -361,3 +383,21 @@ public function __invoke(): void { } } + +#[AsCommand(name: 'invokable-signalable', description: 'Just testing', help: 'The %command.name% help content.')] +class InvokableSignalableCommand implements SignalableCommandInterface +{ + public function __invoke(): void + { + } + + public function getSubscribedSignals(): array + { + return []; + } + + public function handleSignal(int $signal, false|int $previousExitCode = 0): int|false + { + return false; + } +} diff --git a/Tests/Fixtures/application_signalable.php b/Tests/Fixtures/application_signalable.php index c737ba1bf..cc1bae6ac 100644 --- a/Tests/Fixtures/application_signalable.php +++ b/Tests/Fixtures/application_signalable.php @@ -1,6 +1,5 @@ Date: Mon, 12 May 2025 15:44:05 -0400 Subject: [PATCH 28/45] [Console] Invokable command `#[Option]` adjustments - `#[Option] ?string $opt = null` as `VALUE_REQUIRED` - `#[Option] bool|string $opt = false` as `VALUE_OPTIONAL` - `#[Option] ?string $opt = ''` throws exception - allow `#[Option] ?array $opt = null` - more tests... --- Attribute/Option.php | 74 +++++++++++++++++------- Tests/Command/InvokableCommandTest.php | 79 +++++++++++++++++++++----- 2 files changed, 118 insertions(+), 35 deletions(-) diff --git a/Attribute/Option.php b/Attribute/Option.php index 4aea4831e..19c823170 100644 --- a/Attribute/Option.php +++ b/Attribute/Option.php @@ -22,6 +22,7 @@ class Option { private const ALLOWED_TYPES = ['string', 'bool', 'int', 'float', 'array']; + private const ALLOWED_UNION_TYPES = ['bool|string', 'bool|int', 'bool|float']; private string|bool|int|float|array|null $default = null; private array|\Closure $suggestedValues; @@ -56,18 +57,8 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self return null; } - $type = $parameter->getType(); $name = $parameter->getName(); - - if (!$type instanceof \ReflectionNamedType) { - throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command options.', $name)); - } - - $self->typeName = $type->getName(); - - if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) { - throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES))); - } + $type = $parameter->getType(); if (!$parameter->isDefaultValueAvailable()) { throw new LogicException(\sprintf('The option parameter "$%s" must declare a default value.', $name)); @@ -80,16 +71,26 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self $self->default = $parameter->getDefaultValue(); $self->allowNull = $parameter->allowsNull(); - if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) { - throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable when it has a default boolean value.', $name)); + if ($type instanceof \ReflectionUnionType) { + return $self->handleUnion($type); } - if ('string' === $self->typeName && null === $self->default) { - throw new LogicException(\sprintf('The option parameter "$%s" must not have a default of null.', $name)); + if (!$type instanceof \ReflectionNamedType) { + throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped or Intersection types are not supported for command options.', $name)); } - if ('array' === $self->typeName && $self->allowNull) { - throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable.', $name)); + $self->typeName = $type->getName(); + + if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) { + throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES))); + } + + if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) { + throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable when it has a default boolean value.', $name)); + } + + if ($self->allowNull && null !== $self->default) { + throw new LogicException(\sprintf('The option parameter "$%s" must either be not-nullable or have a default of null.', $name)); } if ('bool' === $self->typeName) { @@ -97,11 +98,10 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self if (false !== $self->default) { $self->mode |= InputOption::VALUE_NEGATABLE; } + } elseif ('array' === $self->typeName) { + $self->mode = InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY; } else { - $self->mode = $self->allowNull ? InputOption::VALUE_OPTIONAL : InputOption::VALUE_REQUIRED; - if ('array' === $self->typeName) { - $self->mode |= InputOption::VALUE_IS_ARRAY; - } + $self->mode = InputOption::VALUE_REQUIRED; } if (\is_array($self->suggestedValues) && !\is_callable($self->suggestedValues) && 2 === \count($self->suggestedValues) && ($instance = $parameter->getDeclaringFunction()->getClosureThis()) && $instance::class === $self->suggestedValues[0] && \is_callable([$instance, $self->suggestedValues[1]])) { @@ -129,6 +129,14 @@ 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 ('array' === $this->typeName && $this->allowNull && [] === $value) { + return null; + } + if ('bool' !== $this->typeName) { return $value; } @@ -139,4 +147,28 @@ public function resolveValue(InputInterface $input): mixed return $value ?? $this->default; } + + private function handleUnion(\ReflectionUnionType $type): self + { + $types = array_map( + static fn(\ReflectionType $t) => $t instanceof \ReflectionNamedType ? $t->getName() : null, + $type->getTypes(), + ); + + sort($types); + + $this->typeName = implode('|', array_filter($types)); + + if (!\in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) { + throw new LogicException(\sprintf('The union type for parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $this->name, implode('", "', self::ALLOWED_UNION_TYPES))); + } + + if (false !== $this->default) { + throw new LogicException(\sprintf('The option parameter "$%s" must have a default value of false.', $this->name)); + } + + $this->mode = InputOption::VALUE_OPTIONAL; + + return $this; + } } diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index 88f1b7870..917e2f88f 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -79,6 +79,7 @@ public function testCommandInputOptionDefinition() #[Option(shortcut: 'v')] bool $verbose = false, #[Option(description: 'User groups')] array $groups = [], #[Option(suggestedValues: [self::class, 'getSuggestedRoles'])] array $roles = ['ROLE_USER'], + #[Option] string|bool $opt = false, ): int { return 0; }); @@ -86,7 +87,8 @@ public function testCommandInputOptionDefinition() $timeoutInputOption = $command->getDefinition()->getOption('idle'); self::assertSame('idle', $timeoutInputOption->getName()); self::assertNull($timeoutInputOption->getShortcut()); - self::assertTrue($timeoutInputOption->isValueOptional()); + self::assertTrue($timeoutInputOption->isValueRequired()); + self::assertFalse($timeoutInputOption->isValueOptional()); self::assertFalse($timeoutInputOption->isNegatable()); self::assertNull($timeoutInputOption->getDefault()); @@ -120,6 +122,14 @@ public function testCommandInputOptionDefinition() self::assertTrue($rolesInputOption->hasCompletion()); $rolesInputOption->complete(new CompletionInput(), $suggestions = new CompletionSuggestions()); self::assertSame(['ROLE_ADMIN', 'ROLE_USER'], array_map(static fn (Suggestion $s) => $s->getValue(), $suggestions->getValueSuggestions())); + + $optInputOption = $command->getDefinition()->getOption('opt'); + self::assertSame('opt', $optInputOption->getName()); + self::assertNull($optInputOption->getShortcut()); + self::assertFalse($optInputOption->isValueRequired()); + self::assertTrue($optInputOption->isValueOptional()); + self::assertFalse($optInputOption->isNegatable()); + self::assertFalse($optInputOption->getDefault()); } public function testInvalidArgumentType() @@ -136,7 +146,7 @@ public function testInvalidArgumentType() public function testInvalidOptionType() { $command = new Command('foo'); - $command->setCode(function (#[Option] object $any) {}); + $command->setCode(function (#[Option] ?object $any = null) {}); $this->expectException(LogicException::class); $this->expectExceptionMessage('The type "object" of parameter "$any" is not supported as a command option. Only "string", "bool", "int", "float", "array" types are allowed.'); @@ -262,14 +272,30 @@ public function testNonBinaryInputOptions(array $parameters, array $expected) $command = new Command('foo'); $command->setCode(function ( #[Option] string $a = '', - #[Option] ?string $b = '', - #[Option] array $c = [], - #[Option] array $d = ['a', 'b'], + #[Option] array $b = [], + #[Option] array $c = ['a', 'b'], + #[Option] bool|string $d = false, + #[Option] ?string $e = null, + #[Option] ?array $f = null, + #[Option] int $g = 0, + #[Option] ?int $h = null, + #[Option] float $i = 0.0, + #[Option] ?float $j = null, + #[Option] bool|int $k = false, + #[Option] bool|float $l = false, ) use ($expected): int { $this->assertSame($expected[0], $a); $this->assertSame($expected[1], $b); $this->assertSame($expected[2], $c); $this->assertSame($expected[3], $d); + $this->assertSame($expected[4], $e); + $this->assertSame($expected[5], $f); + $this->assertSame($expected[6], $g); + $this->assertSame($expected[7], $h); + $this->assertSame($expected[8], $i); + $this->assertSame($expected[9], $j); + $this->assertSame($expected[10], $k); + $this->assertSame($expected[11], $l); return 0; }); @@ -279,9 +305,18 @@ public function testNonBinaryInputOptions(array $parameters, array $expected) public static function provideNonBinaryInputOptions(): \Generator { - yield 'defaults' => [[], ['', '', [], ['a', 'b']]]; - yield 'with-value' => [['--a' => 'x', '--b' => 'y', '--c' => ['z'], '--d' => ['c', 'd']], ['x', 'y', ['z'], ['c', 'd']]]; - yield 'without-value' => [['--b' => null], ['', null, [], ['a', 'b']]]; + yield 'defaults' => [ + [], + ['', [], ['a', 'b'], false, null, null, 0, null, 0.0, null, false, false], + ]; + yield 'with-value' => [ + ['--a' => 'x', '--b' => ['z'], '--c' => ['c', 'd'], '--d' => 'v', '--e' => 'w', '--f' => ['q'], '--g' => 1, '--h' => 2, '--i' => 3.1, '--j' => 4.2, '--k' => 5, '--l' => 6.3], + ['x', ['z'], ['c', 'd'], 'v', 'w', ['q'], 1, 2, 3.1, 4.2, 5, 6.3], + ]; + yield 'without-value' => [ + ['--d' => null, '--k' => null, '--l' => null], + ['', [], ['a', 'b'], true, null, null, 0, null, 0.0, null, true, true], + ]; } /** @@ -312,13 +347,29 @@ function (#[Option] ?bool $a = true) {}, function (#[Option] ?bool $a = false) {}, 'The option parameter "$a" must not be nullable when it has a default boolean value.', ]; - yield 'nullable-string' => [ - function (#[Option] ?string $a = null) {}, - 'The option parameter "$a" must not have a default of null.', + yield 'invalid-union-type' => [ + function (#[Option] array|bool $a = false) {}, + 'The union type for parameter "$a" is not supported as a command option. Only "bool|string", "bool|int", "bool|float" types are allowed.', + ]; + yield 'union-type-cannot-allow-null' => [ + function (#[Option] string|bool|null $a = null) {}, + 'The union type for parameter "$a" is not supported as a command option. Only "bool|string", "bool|int", "bool|float" types are allowed.', + ]; + yield 'union-type-default-true' => [ + function (#[Option] string|bool $a = true) {}, + 'The option parameter "$a" must have a default value of false.', + ]; + yield 'union-type-default-string' => [ + function (#[Option] string|bool $a = 'foo') {}, + 'The option parameter "$a" must have a default value of false.', + ]; + yield 'nullable-string-not-null-default' => [ + function (#[Option] ?string $a = 'foo') {}, + 'The option parameter "$a" must either be not-nullable or have a default of null.', ]; - yield 'nullable-array' => [ - function (#[Option] ?array $a = null) {}, - 'The option parameter "$a" must not be nullable.', + yield 'nullable-array-not-null-default' => [ + function (#[Option] ?array $a = []) {}, + 'The option parameter "$a" must either be not-nullable or have a default of null.', ]; } From 66c1440edf6f339fd82ed6c7caa76cb006211b44 Mon Sep 17 00:00:00 2001 From: Kevin Bond Date: Sat, 17 May 2025 19:37:35 -0400 Subject: [PATCH 29/45] [Console] Improve `#[Argument]`/`#[Option]` exception messages --- Attribute/Argument.php | 11 +++++++++-- Attribute/Option.php | 21 ++++++++++++++------- Tests/Command/InvokableCommandTest.php | 22 +++++----------------- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/Attribute/Argument.php b/Attribute/Argument.php index 22bfbf48b..e6a94d2f1 100644 --- a/Attribute/Argument.php +++ b/Attribute/Argument.php @@ -26,6 +26,7 @@ class Argument private string|bool|int|float|array|null $default = null; private array|\Closure $suggestedValues; private ?int $mode = null; + private string $function = ''; /** * Represents a console command definition. @@ -52,17 +53,23 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self return null; } + if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) { + $self->function = $function->class.'::'.$function->name; + } else { + $self->function = $function->name; + } + $type = $parameter->getType(); $name = $parameter->getName(); if (!$type instanceof \ReflectionNamedType) { - throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name)); + throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $name, $self->function)); } $parameterTypeName = $type->getName(); if (!\in_array($parameterTypeName, self::ALLOWED_TYPES, true)) { - throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, implode('", "', self::ALLOWED_TYPES))); + throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command argument. Only "%s" types are allowed.', $parameterTypeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES))); } if (!$self->name) { diff --git a/Attribute/Option.php b/Attribute/Option.php index 19c823170..2f0256b17 100644 --- a/Attribute/Option.php +++ b/Attribute/Option.php @@ -29,6 +29,7 @@ class Option private ?int $mode = null; private string $typeName = ''; private bool $allowNull = false; + private string $function = ''; /** * Represents a console command --option definition. @@ -57,11 +58,17 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self return null; } + if (($function = $parameter->getDeclaringFunction()) instanceof \ReflectionMethod) { + $self->function = $function->class.'::'.$function->name; + } else { + $self->function = $function->name; + } + $name = $parameter->getName(); $type = $parameter->getType(); if (!$parameter->isDefaultValueAvailable()) { - throw new LogicException(\sprintf('The option parameter "$%s" must declare a default value.', $name)); + throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must declare a default value.', $name, $self->function)); } if (!$self->name) { @@ -76,21 +83,21 @@ public static function tryFrom(\ReflectionParameter $parameter): ?self } if (!$type instanceof \ReflectionNamedType) { - throw new LogicException(\sprintf('The parameter "$%s" must have a named type. Untyped or Intersection types are not supported for command options.', $name)); + throw new LogicException(\sprintf('The parameter "$%s" of "%s()" must have a named type. Untyped or Intersection types are not supported for command options.', $name, $self->function)); } $self->typeName = $type->getName(); if (!\in_array($self->typeName, self::ALLOWED_TYPES, true)) { - throw new LogicException(\sprintf('The type "%s" of parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, implode('", "', self::ALLOWED_TYPES))); + throw new LogicException(\sprintf('The type "%s" on parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types are allowed.', $self->typeName, $name, $self->function, implode('", "', self::ALLOWED_TYPES))); } if ('bool' === $self->typeName && $self->allowNull && \in_array($self->default, [true, false], true)) { - throw new LogicException(\sprintf('The option parameter "$%s" must not be nullable when it has a default boolean value.', $name)); + throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must not be nullable when it has a default boolean value.', $name, $self->function)); } if ($self->allowNull && null !== $self->default) { - throw new LogicException(\sprintf('The option parameter "$%s" must either be not-nullable or have a default of null.', $name)); + throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must either be not-nullable or have a default of null.', $name, $self->function)); } if ('bool' === $self->typeName) { @@ -160,11 +167,11 @@ private function handleUnion(\ReflectionUnionType $type): self $this->typeName = implode('|', array_filter($types)); if (!\in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) { - throw new LogicException(\sprintf('The union type for parameter "$%s" is not supported as a command option. Only "%s" types are allowed.', $this->name, implode('", "', self::ALLOWED_UNION_TYPES))); + throw new LogicException(\sprintf('The union type for parameter "$%s" of "%s()" is not supported as a command option. Only "%s" types are allowed.', $this->name, $this->function, implode('", "', self::ALLOWED_UNION_TYPES))); } if (false !== $this->default) { - throw new LogicException(\sprintf('The option parameter "$%s" must have a default value of false.', $this->name)); + throw new LogicException(\sprintf('The option parameter "$%s" of "%s()" must have a default value of false.', $this->name, $this->function)); } $this->mode = InputOption::VALUE_OPTIONAL; diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php index 917e2f88f..5ab7951e7 100644 --- a/Tests/Command/InvokableCommandTest.php +++ b/Tests/Command/InvokableCommandTest.php @@ -138,7 +138,6 @@ public function testInvalidArgumentType() $command->setCode(function (#[Argument] object $any) {}); $this->expectException(LogicException::class); - $this->expectExceptionMessage('The type "object" of parameter "$any" is not supported as a command argument. Only "string", "bool", "int", "float", "array" types are allowed.'); $command->getDefinition(); } @@ -149,7 +148,6 @@ public function testInvalidOptionType() $command->setCode(function (#[Option] ?object $any = null) {}); $this->expectException(LogicException::class); - $this->expectExceptionMessage('The type "object" of parameter "$any" is not supported as a command option. Only "string", "bool", "int", "float", "array" types are allowed.'); $command->getDefinition(); } @@ -322,13 +320,12 @@ public static function provideNonBinaryInputOptions(): \Generator /** * @dataProvider provideInvalidOptionDefinitions */ - public function testInvalidOptionDefinition(callable $code, string $expectedMessage) + public function testInvalidOptionDefinition(callable $code) { $command = new Command('foo'); $command->setCode($code); $this->expectException(LogicException::class); - $this->expectExceptionMessage($expectedMessage); $command->getDefinition(); } @@ -336,40 +333,31 @@ public function testInvalidOptionDefinition(callable $code, string $expectedMess public static function provideInvalidOptionDefinitions(): \Generator { yield 'no-default' => [ - function (#[Option] string $a) {}, - 'The option parameter "$a" must declare a default value.', + function (#[Option] string $a) {} ]; yield 'nullable-bool-default-true' => [ - function (#[Option] ?bool $a = true) {}, - 'The option parameter "$a" must not be nullable when it has a default boolean value.', + function (#[Option] ?bool $a = true) {} ]; yield 'nullable-bool-default-false' => [ - function (#[Option] ?bool $a = false) {}, - 'The option parameter "$a" must not be nullable when it has a default boolean value.', + function (#[Option] ?bool $a = false) {} ]; yield 'invalid-union-type' => [ - function (#[Option] array|bool $a = false) {}, - 'The union type for parameter "$a" is not supported as a command option. Only "bool|string", "bool|int", "bool|float" types are allowed.', + function (#[Option] array|bool $a = false) {} ]; yield 'union-type-cannot-allow-null' => [ function (#[Option] string|bool|null $a = null) {}, - 'The union type for parameter "$a" is not supported as a command option. Only "bool|string", "bool|int", "bool|float" types are allowed.', ]; yield 'union-type-default-true' => [ function (#[Option] string|bool $a = true) {}, - 'The option parameter "$a" must have a default value of false.', ]; yield 'union-type-default-string' => [ function (#[Option] string|bool $a = 'foo') {}, - 'The option parameter "$a" must have a default value of false.', ]; yield 'nullable-string-not-null-default' => [ function (#[Option] ?string $a = 'foo') {}, - 'The option parameter "$a" must either be not-nullable or have a default of null.', ]; yield 'nullable-array-not-null-default' => [ function (#[Option] ?array $a = []) {}, - 'The option parameter "$a" must either be not-nullable or have a default of null.', ]; } From b4ee0d4d076a4f127187ed301ec1e6c51d96cb56 Mon Sep 17 00:00:00 2001 From: Julien Tattevin Date: Wed, 9 Jul 2025 13:49:55 +0200 Subject: [PATCH 30/45] [Console] Fix `TreeHelper::addChild` when providing a string --- Helper/TreeNode.php | 2 +- Tests/Helper/TreeHelperTest.php | 20 ++++++++++++++++++++ Tests/Helper/TreeNodeTest.php | 18 ++++++++++++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Helper/TreeNode.php b/Helper/TreeNode.php index 7f2ed8a4a..8c35266c1 100644 --- a/Helper/TreeNode.php +++ b/Helper/TreeNode.php @@ -58,7 +58,7 @@ public function getValue(): string public function addChild(self|string|callable $node): self { if (\is_string($node)) { - $node = new self($node, $this); + $node = new self($node); } $this->children[] = $node; diff --git a/Tests/Helper/TreeHelperTest.php b/Tests/Helper/TreeHelperTest.php index 15ec0f0b2..5d1399b27 100644 --- a/Tests/Helper/TreeHelperTest.php +++ b/Tests/Helper/TreeHelperTest.php @@ -195,6 +195,26 @@ public function testRenderNodeWithMultipleChildren() TREE, self::normalizeLineBreaks(trim($output->fetch()))); } + public function testRenderNodeWithMultipleChildrenWithStringConversion() + { + $rootNode = new TreeNode('Root'); + + $rootNode->addChild('Child 1'); + $rootNode->addChild('Child 2'); + $rootNode->addChild('Child 3'); + + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame(<<fetch()))); + } + public function testRenderTreeWithDuplicateNodeNames() { $rootNode = new TreeNode('Root'); diff --git a/Tests/Helper/TreeNodeTest.php b/Tests/Helper/TreeNodeTest.php index 981e7ea47..0e80da3bd 100644 --- a/Tests/Helper/TreeNodeTest.php +++ b/Tests/Helper/TreeNodeTest.php @@ -34,6 +34,24 @@ public function testAddingChildren() $this->assertSame($child, iterator_to_array($root->getChildren())[0]); } + public function testAddingChildrenAsString() + { + $root = new TreeNode('Root'); + + $root->addChild('Child 1'); + $root->addChild('Child 2'); + + $this->assertSame(2, iterator_count($root->getChildren())); + + $children = iterator_to_array($root->getChildren()); + + $this->assertSame(0, iterator_count($children[0]->getChildren())); + $this->assertSame(0, iterator_count($children[1]->getChildren())); + + $this->assertSame('Child 1', $children[0]->getValue()); + $this->assertSame('Child 2', $children[1]->getValue()); + } + public function testAddingChildrenWithGenerators() { $root = new TreeNode('Root'); From 0ca45ae88acb8af4f68acb9f1c562026ae026b1f Mon Sep 17 00:00:00 2001 From: schlndh Date: Mon, 4 Aug 2025 09:50:33 +0200 Subject: [PATCH 31/45] [Console][Table] Don't split grapheme clusters --- Formatter/OutputFormatter.php | 2 +- Tests/Formatter/OutputFormatterTest.php | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/Formatter/OutputFormatter.php b/Formatter/OutputFormatter.php index c37a4d452..a30e44d84 100644 --- a/Formatter/OutputFormatter.php +++ b/Formatter/OutputFormatter.php @@ -285,6 +285,6 @@ private function addLineBreaks(string $text, int $width): string { $encoding = mb_detect_encoding($text, null, true) ?: 'UTF-8'; - return b($text)->toCodePointString($encoding)->wordwrap($width, "\n", true)->toByteString($encoding); + return b($text)->toUnicodeString($encoding)->wordwrap($width, "\n", true)->toByteString($encoding); } } diff --git a/Tests/Formatter/OutputFormatterTest.php b/Tests/Formatter/OutputFormatterTest.php index b66b6abe4..489108bd5 100644 --- a/Tests/Formatter/OutputFormatterTest.php +++ b/Tests/Formatter/OutputFormatterTest.php @@ -373,6 +373,7 @@ public function testFormatAndWrap() $this->assertSame("foobar\e[37;41mbaz\e[39;49m\n\e[37;41mnewline\e[39;49m", $formatter->formatAndWrap("foobarbaz\nnewline", 11)); $this->assertSame("foobar\e[37;41mbazne\e[39;49m\n\e[37;41mwline\e[39;49m", $formatter->formatAndWrap("foobarbazne\nwline", 11)); $this->assertSame("foobar\e[37;41mbazne\e[39;49m\n\e[37;41mw\e[39;49m\n\e[37;41mline\e[39;49m", $formatter->formatAndWrap("foobarbaznew\nline", 11)); + $this->assertSame("\e[37;41m👩‍🌾\e[39;49m", $formatter->formatAndWrap('👩‍🌾', 1)); $formatter = new OutputFormatter(); @@ -392,6 +393,7 @@ public function testFormatAndWrap() $this->assertSame("foobarbaz\nnewline", $formatter->formatAndWrap("foobarbaz\nnewline", 11)); $this->assertSame("foobarbazne\nwline", $formatter->formatAndWrap("foobarbazne\nwline", 11)); $this->assertSame("foobarbazne\nw\nline", $formatter->formatAndWrap("foobarbaznew\nline", 11)); + $this->assertSame('👩‍🌾', $formatter->formatAndWrap('👩‍🌾', 1)); } } From ad2f59fd918ffab3eaa723c8f6c5c6f0dc435d02 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gr=C3=A9goire=20Pineau?= Date: Fri, 4 Jul 2025 10:51:27 +0200 Subject: [PATCH 32/45] [Console] Restore SHELL_VERBOSITY after a command is ran --- Application.php | 18 +++++++ Tester/ApplicationTester.php | 38 ++++---------- Tests/ApplicationTest.php | 99 +++++++++++++++++++++++++++++++++++- 3 files changed, 125 insertions(+), 30 deletions(-) diff --git a/Application.php b/Application.php index f0e0a303e..1ea644df0 100644 --- a/Application.php +++ b/Application.php @@ -186,6 +186,8 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu } } + $prevShellVerbosity = getenv('SHELL_VERBOSITY'); + try { $this->configureIO($input, $output); @@ -223,6 +225,22 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu $phpHandler[0]->setExceptionHandler($finalHandler); } } + + // SHELL_VERBOSITY is set by Application::configureIO so we need to unset/reset it + // to its previous value to avoid one command verbosity to spread to other commands + if (false === $prevShellVerbosity) { + if (\function_exists('putenv')) { + @putenv('SHELL_VERBOSITY'); + } + unset($_ENV['SHELL_VERBOSITY']); + unset($_SERVER['SHELL_VERBOSITY']); + } else { + if (\function_exists('putenv')) { + @putenv('SHELL_VERBOSITY='.$prevShellVerbosity); + } + $_ENV['SHELL_VERBOSITY'] = $prevShellVerbosity; + $_SERVER['SHELL_VERBOSITY'] = $prevShellVerbosity; + } } if ($this->autoExit) { diff --git a/Tester/ApplicationTester.php b/Tester/ApplicationTester.php index cebb6f8eb..a6dc8e1ce 100644 --- a/Tester/ApplicationTester.php +++ b/Tester/ApplicationTester.php @@ -47,37 +47,17 @@ public function __construct( */ public function run(array $input, array $options = []): int { - $prevShellVerbosity = getenv('SHELL_VERBOSITY'); - - try { - $this->input = new ArrayInput($input); - if (isset($options['interactive'])) { - $this->input->setInteractive($options['interactive']); - } + $this->input = new ArrayInput($input); + if (isset($options['interactive'])) { + $this->input->setInteractive($options['interactive']); + } - if ($this->inputs) { - $this->input->setStream(self::createStream($this->inputs)); - } + if ($this->inputs) { + $this->input->setStream(self::createStream($this->inputs)); + } - $this->initOutput($options); + $this->initOutput($options); - return $this->statusCode = $this->application->run($this->input, $this->output); - } finally { - // SHELL_VERBOSITY is set by Application::configureIO so we need to unset/reset it - // to its previous value to avoid one test's verbosity to spread to the following tests - if (false === $prevShellVerbosity) { - if (\function_exists('putenv')) { - @putenv('SHELL_VERBOSITY'); - } - unset($_ENV['SHELL_VERBOSITY']); - unset($_SERVER['SHELL_VERBOSITY']); - } else { - if (\function_exists('putenv')) { - @putenv('SHELL_VERBOSITY='.$prevShellVerbosity); - } - $_ENV['SHELL_VERBOSITY'] = $prevShellVerbosity; - $_SERVER['SHELL_VERBOSITY'] = $prevShellVerbosity; - } - } + return $this->statusCode = $this->application->run($this->input, $this->output); } } diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 268f8ba50..6390d4828 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -37,6 +37,7 @@ use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; +use Symfony\Component\Console\Output\BufferedOutput; use Symfony\Component\Console\Output\ConsoleOutput; use Symfony\Component\Console\Output\NullOutput; use Symfony\Component\Console\Output\Output; @@ -831,7 +832,7 @@ public function testSetCatchErrors(bool $catchExceptions) try { $tester->run(['command' => 'boom']); - $this->fail('The exception is not catched.'); + $this->fail('The exception is not caught.'); } catch (\Throwable $e) { $this->assertInstanceOf(\Error::class, $e); $this->assertSame('This is an error.', $e->getMessage()); @@ -2463,6 +2464,102 @@ private function createSignalableApplication(Command $command, ?EventDispatcherI return $application; } + + public function testShellVerbosityIsRestoredAfterCommandExecutionWithInitialValue() + { + // Set initial SHELL_VERBOSITY + putenv('SHELL_VERBOSITY=-2'); + $_ENV['SHELL_VERBOSITY'] = '-2'; + $_SERVER['SHELL_VERBOSITY'] = '-2'; + + $application = new Application(); + $application->setAutoExit(false); + $application->register('foo') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->write('SHELL_VERBOSITY: '.$_SERVER['SHELL_VERBOSITY']); + + return 0; + }); + + $input = new ArrayInput(['command' => 'foo', '--verbose' => 3]); + $output = new BufferedOutput(); + + $application->run($input, $output); + + $this->assertSame('SHELL_VERBOSITY: 3', $output->fetch()); + $this->assertSame('-2', getenv('SHELL_VERBOSITY')); + $this->assertSame('-2', $_ENV['SHELL_VERBOSITY']); + $this->assertSame('-2', $_SERVER['SHELL_VERBOSITY']); + + // Clean up for other tests + putenv('SHELL_VERBOSITY'); + unset($_ENV['SHELL_VERBOSITY']); + unset($_SERVER['SHELL_VERBOSITY']); + } + + public function testShellVerbosityIsRemovedAfterCommandExecutionWhenNotSetInitially() + { + // Ensure SHELL_VERBOSITY is not set initially + putenv('SHELL_VERBOSITY'); + unset($_ENV['SHELL_VERBOSITY']); + unset($_SERVER['SHELL_VERBOSITY']); + + $application = new Application(); + $application->setAutoExit(false); + $application->register('foo') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->write('SHELL_VERBOSITY: '.$_SERVER['SHELL_VERBOSITY']); + + return 0; + }); + + $input = new ArrayInput(['command' => 'foo', '--verbose' => 3]); + $output = new BufferedOutput(); + + $application->run($input, $output); + + $this->assertSame('SHELL_VERBOSITY: 3', $output->fetch()); + $this->assertFalse(getenv('SHELL_VERBOSITY')); + $this->assertArrayNotHasKey('SHELL_VERBOSITY', $_ENV); + $this->assertArrayNotHasKey('SHELL_VERBOSITY', $_SERVER); + } + + public function testShellVerbosityDoesNotLeakBetweenCommandExecutions() + { + // Ensure no initial SHELL_VERBOSITY + putenv('SHELL_VERBOSITY'); + unset($_ENV['SHELL_VERBOSITY']); + unset($_SERVER['SHELL_VERBOSITY']); + + $application = new Application(); + $application->setAutoExit(false); + $application->register('verbose-cmd') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->write('SHELL_VERBOSITY: '.$_SERVER['SHELL_VERBOSITY']); + + return 0; + }); + $application->register('normal-cmd') + ->setCode(function (InputInterface $input, OutputInterface $output): int { + $output->write('SHELL_VERBOSITY: '.$_SERVER['SHELL_VERBOSITY']); + + return 0; + }); + + $output = new BufferedOutput(); + + $application->run(new ArrayInput(['command' => 'verbose-cmd', '--verbose' => true]), $output); + + $this->assertSame('SHELL_VERBOSITY: 1', $output->fetch(), 'SHELL_VERBOSITY should be set to 1 for verbose command'); + $this->assertFalse(getenv('SHELL_VERBOSITY'), 'SHELL_VERBOSITY should not be set after first command'); + + $application->run(new ArrayInput(['command' => 'normal-cmd']), $output); + + $this->assertSame('SHELL_VERBOSITY: 0', $output->fetch(), 'SHELL_VERBOSITY should not leak to second command'); + $this->assertFalse(getenv('SHELL_VERBOSITY'), 'SHELL_VERBOSITY should not leak to second command'); + $this->assertArrayNotHasKey('SHELL_VERBOSITY', $_ENV); + $this->assertArrayNotHasKey('SHELL_VERBOSITY', $_SERVER); + } } class CustomApplication extends Application From 273fd29ff30ba0a88ca5fb83f7cf1ab69306adae Mon Sep 17 00:00:00 2001 From: matlec Date: Fri, 22 Aug 2025 11:58:45 +0200 Subject: [PATCH 33/45] [Console] Fix testing multiline question --- Helper/QuestionHelper.php | 6 +++++- Tester/TesterTrait.php | 4 ++++ Tests/Helper/QuestionHelperTest.php | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 3d9091d2b..593b01b39 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -527,12 +527,16 @@ private function readInput($inputStream, Question $question): string|false $ret = ''; $cp = $this->setIOCodepage(); while (false !== ($char = fgetc($multiLineStreamReader))) { - if (\PHP_EOL === "{$ret}{$char}") { + if ("\x4" === $char || \PHP_EOL === "{$ret}{$char}") { break; } $ret .= $char; } + if (stream_get_meta_data($inputStream)['seekable']) { + fseek($inputStream, ftell($multiLineStreamReader)); + } + return $this->resetIOCodepage($cp, $ret); } diff --git a/Tester/TesterTrait.php b/Tester/TesterTrait.php index 1ab7a70aa..127556d1d 100644 --- a/Tester/TesterTrait.php +++ b/Tester/TesterTrait.php @@ -169,6 +169,10 @@ private static function createStream(array $inputs) foreach ($inputs as $input) { fwrite($stream, $input.\PHP_EOL); + + if (str_contains($input, \PHP_EOL)) { + fwrite($stream, "\x4"); + } } rewind($stream); diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index 42da50273..651ae5f10 100644 --- a/Tests/Helper/QuestionHelperTest.php +++ b/Tests/Helper/QuestionHelperTest.php @@ -519,7 +519,7 @@ public function testAskMultilineResponseWithWithCursorInMiddleOfSeekableInputStr $question->setMultiline(true); $this->assertSame("some\ninput", $dialog->ask($this->createStreamableInputInterfaceMock($response), $this->createOutputInterface(), $question)); - $this->assertSame(8, ftell($response)); + $this->assertSame(18, ftell($response)); } /** From 6e654c04d1101993e2ea127b6fcd8cdc7dfda5cc Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Fri, 5 Sep 2025 14:17:45 +0200 Subject: [PATCH 34/45] use the empty string instead of null as an array offset --- Input/ArgvInput.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Input/ArgvInput.php b/Input/ArgvInput.php index a33092aee..b5f866689 100644 --- a/Input/ArgvInput.php +++ b/Input/ArgvInput.php @@ -176,7 +176,7 @@ private function parseArgument(string $token): void } else { $all = $this->definition->getArguments(); $symfonyCommandName = null; - if (($inputArgument = $all[$key = array_key_first($all)] ?? null) && 'command' === $inputArgument->getName()) { + if (($inputArgument = $all[$key = array_key_first($all) ?? ''] ?? null) && 'command' === $inputArgument->getName()) { $symfonyCommandName = $this->arguments['command'] ?? null; unset($all[$key]); } From 78a6b2bf4e5246ed5824c6b617169ec13e75b5e3 Mon Sep 17 00:00:00 2001 From: matlec Date: Mon, 8 Sep 2025 12:28:06 +0200 Subject: [PATCH 35/45] =?UTF-8?q?[Console]=20Don=E2=80=99t=20automatically?= =?UTF-8?q?=20append=20EOT=20to=20multiline=20test=20inputs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tester/TesterTrait.php | 4 ---- Tests/Tester/CommandTesterTest.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/Tester/TesterTrait.php b/Tester/TesterTrait.php index 127556d1d..1ab7a70aa 100644 --- a/Tester/TesterTrait.php +++ b/Tester/TesterTrait.php @@ -169,10 +169,6 @@ private static function createStream(array $inputs) foreach ($inputs as $input) { fwrite($stream, $input.\PHP_EOL); - - if (str_contains($input, \PHP_EOL)) { - fwrite($stream, "\x4"); - } } rewind($stream); diff --git a/Tests/Tester/CommandTesterTest.php b/Tests/Tester/CommandTesterTest.php index ce0a24b99..b974f942a 100644 --- a/Tests/Tester/CommandTesterTest.php +++ b/Tests/Tester/CommandTesterTest.php @@ -16,7 +16,9 @@ use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Helper\HelperSet; use Symfony\Component\Console\Helper\QuestionHelper; +use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\Output; +use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Question\ChoiceQuestion; use Symfony\Component\Console\Question\Question; use Symfony\Component\Console\Style\SymfonyStyle; @@ -127,6 +129,32 @@ public function testCommandWithInputs() $this->assertEquals(implode('', $questions), $tester->getDisplay(true)); } + public function testCommandWithMultilineInputs() + { + $question = 'What is your address?'; + + $command = new Command('foo'); + $command->setHelperSet(new HelperSet([new QuestionHelper()])); + $command->setCode(function (InputInterface $input, OutputInterface $output) use ($question, $command): int { + $output->write($command->getHelper('question')->ask($input, $output, (new Question($question."\n"))->setMultiline(true))); + $output->write(stream_get_contents($input->getStream())); + + return 0; + }); + + $tester = new CommandTester($command); + + $address = <<
setInputs([$address."\x04", $address]); + $tester->execute([]); + + $tester->assertCommandIsSuccessful(); + $this->assertSame($question."\n".$address."\n".$address."\n", $tester->getDisplay()); + } + public function testCommandWithDefaultInputs() { $questions = [ From 7d36330ef219616ee9c0c3412e6bc6ac0faec691 Mon Sep 17 00:00:00 2001 From: matlec Date: Mon, 8 Sep 2025 16:58:47 +0200 Subject: [PATCH 36/45] =?UTF-8?q?[Console]=20Don=E2=80=99t=20append=20a=20?= =?UTF-8?q?new=20line=20to=20test=20inputs=20ending=20with=20an=20EOT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tester/TesterTrait.php | 6 +++++- Tests/Tester/CommandTesterTest.php | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/Tester/TesterTrait.php b/Tester/TesterTrait.php index 1ab7a70aa..238c7b7eb 100644 --- a/Tester/TesterTrait.php +++ b/Tester/TesterTrait.php @@ -168,7 +168,11 @@ private static function createStream(array $inputs) $stream = fopen('php://memory', 'r+', false); foreach ($inputs as $input) { - fwrite($stream, $input.\PHP_EOL); + fwrite($stream, $input); + + if (!str_ends_with($input, "\x4")) { + fwrite($stream, \PHP_EOL); + } } rewind($stream); diff --git a/Tests/Tester/CommandTesterTest.php b/Tests/Tester/CommandTesterTest.php index b974f942a..799618a1e 100644 --- a/Tests/Tester/CommandTesterTest.php +++ b/Tests/Tester/CommandTesterTest.php @@ -136,7 +136,7 @@ public function testCommandWithMultilineInputs() $command = new Command('foo'); $command->setHelperSet(new HelperSet([new QuestionHelper()])); $command->setCode(function (InputInterface $input, OutputInterface $output) use ($question, $command): int { - $output->write($command->getHelper('question')->ask($input, $output, (new Question($question."\n"))->setMultiline(true))); + $output->write($command->getHelper('question')->ask($input, $output, (new Question($question))->setMultiline(true))); $output->write(stream_get_contents($input->getStream())); return 0; @@ -152,7 +152,7 @@ public function testCommandWithMultilineInputs() $tester->execute([]); $tester->assertCommandIsSuccessful(); - $this->assertSame($question."\n".$address."\n".$address."\n", $tester->getDisplay()); + $this->assertSame($question.$address.$address.\PHP_EOL, $tester->getDisplay()); } public function testCommandWithDefaultInputs() From 5b692fe889b4dcd886acf9a3141ef35f0241bd3c Mon Sep 17 00:00:00 2001 From: HypeMC Date: Thu, 11 Sep 2025 05:17:31 +0200 Subject: [PATCH 37/45] [Console] Fix handling of `\E` in Bash completion --- Resources/completion.bash | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Resources/completion.bash b/Resources/completion.bash index 64c6a338f..2befe76cb 100644 --- a/Resources/completion.bash +++ b/Resources/completion.bash @@ -37,7 +37,7 @@ _sf_{{ COMMAND_NAME }}() { local completecmd=("$sf_cmd" "_complete" "--no-interaction" "-sbash" "-c$cword" "-a{{ VERSION }}") for w in ${words[@]}; do - w=$(printf -- '%b' "$w") + w="${w//\\\\/\\}" # remove quotes from typed values quote="${w:0:1}" if [ "$quote" == \' ]; then From 6ef58ef9f52d2466addd42ae0689b0b5c1a19e2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=B4me=20Tamarelle?= Date: Sun, 21 Sep 2025 14:45:07 +0200 Subject: [PATCH 38/45] [Console] Specify types of interactive question choices --- Question/ChoiceQuestion.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Question/ChoiceQuestion.php b/Question/ChoiceQuestion.php index 9445ccc0c..cdcbcb529 100644 --- a/Question/ChoiceQuestion.php +++ b/Question/ChoiceQuestion.php @@ -26,9 +26,9 @@ class ChoiceQuestion extends Question private string $errorMessage = 'Value "%s" is invalid'; /** - * @param string $question The question to ask to the user - * @param array $choices The list of available choices - * @param string|bool|int|float|null $default The default answer to return + * @param string $question The question to ask to the user + * @param array $choices The list of available choices + * @param string|bool|int|float|null $default The default answer to return */ public function __construct(string $question, array $choices, string|bool|int|float|null $default = null) { @@ -44,7 +44,7 @@ public function __construct(string $question, array $choices, string|bool|int|fl } /** - * Returns available choices. + * @return array */ public function getChoices(): array { From d42098fad3fd7e0ad98b078ad0312f2a7d475ad7 Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Thu, 25 Sep 2025 11:18:42 +0200 Subject: [PATCH 39/45] fix transient Console output related test --- Tests/Helper/ProcessHelperTest.php | 133 +++++++++++++++-------------- 1 file changed, 67 insertions(+), 66 deletions(-) diff --git a/Tests/Helper/ProcessHelperTest.php b/Tests/Helper/ProcessHelperTest.php index 1fd88987b..ac7363fba 100644 --- a/Tests/Helper/ProcessHelperTest.php +++ b/Tests/Helper/ProcessHelperTest.php @@ -23,7 +23,7 @@ class ProcessHelperTest extends TestCase /** * @dataProvider provideCommandsAndOutput */ - public function testVariousProcessRuns(string $expected, Process|string|array $cmd, int $verbosity, ?string $error) + public function testVariousProcessRuns(array $expectedOutputLines, bool $successful, Process|string|array $cmd, int $verbosity, ?string $error) { if (\is_string($cmd)) { $cmd = Process::fromShellCommandline($cmd); @@ -31,9 +31,57 @@ public function testVariousProcessRuns(string $expected, Process|string|array $c $helper = new ProcessHelper(); $helper->setHelperSet(new HelperSet([new DebugFormatterHelper()])); - $output = $this->getOutputStream($verbosity); - $helper->run($output, $cmd, $error); - $this->assertEquals($expected, $this->getOutput($output)); + $outputStream = $this->getOutputStream($verbosity); + $helper->run($outputStream, $cmd, $error); + + $expectedLines = 1 + \count($expectedOutputLines); + + if (StreamOutput::VERBOSITY_VERY_VERBOSE <= $verbosity) { + // the executed command and the result are displayed + $expectedLines += 2; + } + + if (null !== $error) { + ++$expectedLines; + } + + $output = explode("\n", $this->getOutput($outputStream)); + + $this->assertCount($expectedLines, $output); + + // remove the trailing newline + array_pop($output); + + if (null !== $error) { + $this->assertSame($error, array_pop($output)); + } + + if (StreamOutput::VERBOSITY_VERY_VERBOSE <= $verbosity) { + if ($cmd instanceof Process) { + $expectedCommandLine = $cmd->getCommandLine(); + } elseif (\is_array($cmd) && $cmd[0] instanceof Process) { + $expectedCommandLine = $cmd[0]->getCommandLine(); + } elseif (\is_array($cmd)) { + $expectedCommandLine = (new Process($cmd))->getCommandLine(); + } else { + $expectedCommandLine = $cmd; + } + + $this->assertSame(' RUN '.$expectedCommandLine, array_shift($output)); + + if ($successful) { + $this->assertSame(' RES Command ran successfully', array_pop($output)); + } else { + $this->assertSame(' RES 252 Command did not run successfully', array_pop($output)); + } + } + + if ([] !== $expectedOutputLines) { + sort($expectedOutputLines); + sort($output); + + $this->assertEquals($expectedOutputLines, $output); + } } public function testPassedCallbackIsExecuted() @@ -51,70 +99,23 @@ public function testPassedCallbackIsExecuted() public static function provideCommandsAndOutput(): array { - $successOutputVerbose = <<<'EOT' - RUN php -r "echo 42;" - RES Command ran successfully - -EOT; - $successOutputDebug = <<<'EOT' - RUN php -r "echo 42;" - OUT 42 - RES Command ran successfully - -EOT; - $successOutputDebugWithTags = <<<'EOT' - RUN php -r "echo '42';" - OUT 42 - RES Command ran successfully - -EOT; - $successOutputProcessDebug = <<<'EOT' - RUN 'php' '-r' 'echo 42;' - OUT 42 - RES Command ran successfully - -EOT; - $syntaxErrorOutputVerbose = <<<'EOT' - RUN php -r "fwrite(STDERR, 'error message');usleep(50000);fwrite(STDOUT, 'out message');exit(252);" - RES 252 Command did not run successfully - -EOT; - $syntaxErrorOutputDebug = <<<'EOT' - RUN php -r "fwrite(STDERR, 'error message');usleep(500000);fwrite(STDOUT, 'out message');exit(252);" - ERR error message - OUT out message - RES 252 Command did not run successfully - -EOT; - $PHP = '\\' === \DIRECTORY_SEPARATOR ? '"!PHP!"' : '"$PHP"'; - $successOutputPhp = <<getCommandLine(); - $successOutputProcessDebug = str_replace("'php' '-r' 'echo 42;'", $args, $successOutputProcessDebug); return [ - ['', 'php -r "echo 42;"', StreamOutput::VERBOSITY_VERBOSE, null], - [$successOutputVerbose, 'php -r "echo 42;"', StreamOutput::VERBOSITY_VERY_VERBOSE, null], - [$successOutputDebug, 'php -r "echo 42;"', StreamOutput::VERBOSITY_DEBUG, null], - [$successOutputDebugWithTags, 'php -r "echo \'42\';"', StreamOutput::VERBOSITY_DEBUG, null], - ['', 'php -r "syntax error"', StreamOutput::VERBOSITY_VERBOSE, null], - [$syntaxErrorOutputVerbose, 'php -r "fwrite(STDERR, \'error message\');usleep(50000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_VERY_VERBOSE, null], - [$syntaxErrorOutputDebug, 'php -r "fwrite(STDERR, \'error message\');usleep(500000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_DEBUG, null], - [$errorMessage.\PHP_EOL, 'php -r "fwrite(STDERR, \'error message\');usleep(50000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_VERBOSE, $errorMessage], - [$syntaxErrorOutputVerbose.$errorMessage.\PHP_EOL, 'php -r "fwrite(STDERR, \'error message\');usleep(50000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_VERY_VERBOSE, $errorMessage], - [$syntaxErrorOutputDebug.$errorMessage.\PHP_EOL, 'php -r "fwrite(STDERR, \'error message\');usleep(500000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_DEBUG, $errorMessage], - [$successOutputProcessDebug, ['php', '-r', 'echo 42;'], StreamOutput::VERBOSITY_DEBUG, null], - [$successOutputDebug, Process::fromShellCommandline('php -r "echo 42;"'), StreamOutput::VERBOSITY_DEBUG, null], - [$successOutputProcessDebug, [new Process(['php', '-r', 'echo 42;'])], StreamOutput::VERBOSITY_DEBUG, null], - [$successOutputPhp, [Process::fromShellCommandline('php -r '.$PHP), 'PHP' => 'echo 42;'], StreamOutput::VERBOSITY_DEBUG, null], + [[], true, 'php -r "echo 42;"', StreamOutput::VERBOSITY_VERBOSE, null], + [[], true, 'php -r "echo 42;"', StreamOutput::VERBOSITY_VERY_VERBOSE, null], + [[' OUT 42'], true, 'php -r "echo 42;"', StreamOutput::VERBOSITY_DEBUG, null], + [[' OUT 42'], true, 'php -r "echo \'42\';"', StreamOutput::VERBOSITY_DEBUG, null], + [[], false, 'php -r "syntax error"', StreamOutput::VERBOSITY_VERBOSE, null], + [[], false, 'php -r "fwrite(STDERR, \'error message\');usleep(50000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_VERY_VERBOSE, null], + [[' ERR error message', ' OUT out message'], false, 'php -r "fwrite(STDERR, \'error message\');usleep(50000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_DEBUG, null], + [[], false, 'php -r "fwrite(STDERR, \'error message\');usleep(50000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_VERBOSE, 'An error occurred'], + [[], false, 'php -r "fwrite(STDERR, \'error message\');usleep(50000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_VERY_VERBOSE, 'An error occurred'], + [[' ERR error message', ' OUT out message'], false, 'php -r "fwrite(STDERR, \'error message\');usleep(500000);fwrite(STDOUT, \'out message\');exit(252);"', StreamOutput::VERBOSITY_DEBUG, 'An error occurred'], + [[' OUT 42'], true, ['php', '-r', 'echo 42;'], StreamOutput::VERBOSITY_DEBUG, null], + [[' OUT 42'], true, Process::fromShellCommandline('php -r "echo 42;"'), StreamOutput::VERBOSITY_DEBUG, null], + [[' OUT 42'], true, [new Process(['php', '-r', 'echo 42;'])], StreamOutput::VERBOSITY_DEBUG, null], + [[' OUT 42'], true, [Process::fromShellCommandline('php -r '.$PHP), 'PHP' => 'echo 42;'], StreamOutput::VERBOSITY_DEBUG, null], ]; } @@ -127,6 +128,6 @@ private function getOutput(StreamOutput $output): string { rewind($output->getStream()); - return stream_get_contents($output->getStream()); + return str_replace(\PHP_EOL, "\n", stream_get_contents($output->getStream())); } } From 492de6dfd93910d7d7a729c5a04ddcd2b9e99c4f Mon Sep 17 00:00:00 2001 From: Christian Flothmann Date: Fri, 26 Sep 2025 13:56:47 +0200 Subject: [PATCH 40/45] do not pass the empty string to ord() --- Helper/QuestionHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 593b01b39..dd0e67d89 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -317,7 +317,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu $ofs += ('A' === $c[2]) ? -1 : 1; $ofs = ($numMatches + $ofs) % $numMatches; } - } elseif (\ord($c) < 32) { + } elseif ('' === $c || \ord($c) < 32) { if ("\t" === $c || "\n" === $c) { if ($numMatches > 0 && -1 !== $ofs) { $ret = (string) $matches[$ofs]; From 250376ab38529b9540e56a76c4120c7f6d29b915 Mon Sep 17 00:00:00 2001 From: John Stevenson Date: Mon, 22 Sep 2025 20:04:19 +0100 Subject: [PATCH 41/45] [Console] Ensure terminal is usable after termination signal --- Application.php | 8 -- Helper/QuestionHelper.php | 29 ++--- Helper/TerminalInputHelper.php | 144 ++++++++++++++++++++++ Tests/ApplicationTest.php | 38 +++++- Tests/Fixtures/application_signalable.php | 2 +- Tests/Fixtures/application_sttyhelper.php | 37 ++++++ 6 files changed, 230 insertions(+), 28 deletions(-) create mode 100644 Helper/TerminalInputHelper.php create mode 100644 Tests/Fixtures/application_sttyhelper.php diff --git a/Application.php b/Application.php index c18d482b4..dd562f0eb 100644 --- a/Application.php +++ b/Application.php @@ -1018,14 +1018,6 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI throw new RuntimeException('Unable to subscribe to signal events. Make sure that the "pcntl" extension is installed and that "pcntl_*" functions are not disabled by your php.ini\'s "disable_functions" directive.'); } - if (Terminal::hasSttyAvailable()) { - $sttyMode = shell_exec('stty -g'); - - foreach ([\SIGINT, \SIGTERM] as $signal) { - $this->signalRegistry->register($signal, static fn () => shell_exec('stty '.$sttyMode)); - } - } - if ($this->dispatcher) { // We register application signals, so that we can dispatch the event foreach ($this->signalsToDispatchEvent as $signal) { diff --git a/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 593b01b39..aa0345903 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -258,11 +258,7 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu $ofs = -1; $matches = $autocomplete($ret); $numMatches = \count($matches); - - $sttyMode = shell_exec('stty -g'); - $isStdin = 'php://stdin' === (stream_get_meta_data($inputStream)['uri'] ?? null); - $r = [$inputStream]; - $w = []; + $inputHelper = new TerminalInputHelper($inputStream); // Disable icanon (so we can fread each keypress) and echo (we'll do echoing here instead) shell_exec('stty -icanon -echo'); @@ -272,15 +268,13 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu // Read a keypress while (!feof($inputStream)) { - while ($isStdin && 0 === @stream_select($r, $w, $w, 0, 100)) { - // Give signal handlers a chance to run - $r = [$inputStream]; - } + $inputHelper->waitForInput(); $c = fread($inputStream, 1); // as opposed to fgets(), fread() returns an empty string when the stream content is empty, not false. if (false === $c || ('' === $ret && '' === $c && null === $question->getDefault())) { - shell_exec('stty '.$sttyMode); + // Restore the terminal so it behaves normally again + $inputHelper->finish(); throw new MissingInputException('Aborted.'); } elseif ("\177" === $c) { // Backspace Character if (0 === $numMatches && 0 !== $i) { @@ -382,8 +376,8 @@ private function autocomplete(OutputInterface $output, Question $question, $inpu } } - // Reset stty so it behaves normally again - shell_exec('stty '.$sttyMode); + // Restore the terminal so it behaves normally again + $inputHelper->finish(); return $fullChoice; } @@ -434,13 +428,17 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $ return $value; } + $inputHelper = null; + if (self::$stty && Terminal::hasSttyAvailable()) { - $sttyMode = shell_exec('stty -g'); + $inputHelper = new TerminalInputHelper($inputStream); shell_exec('stty -echo'); } elseif ($this->isInteractiveInput($inputStream)) { throw new RuntimeException('Unable to hide the response.'); } + $inputHelper?->waitForInput(); + $value = fgets($inputStream, 4096); if (4095 === \strlen($value)) { @@ -448,9 +446,8 @@ private function getHiddenResponse(OutputInterface $output, $inputStream, bool $ $errOutput->warning('The value was possibly truncated by your shell or terminal emulator'); } - if (self::$stty && Terminal::hasSttyAvailable()) { - shell_exec('stty '.$sttyMode); - } + // Restore the terminal so it behaves normally again + $inputHelper?->finish(); if (false === $value) { throw new MissingInputException('Aborted.'); diff --git a/Helper/TerminalInputHelper.php b/Helper/TerminalInputHelper.php new file mode 100644 index 000000000..750229a8f --- /dev/null +++ b/Helper/TerminalInputHelper.php @@ -0,0 +1,144 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Console\Helper; + +/** + * TerminalInputHelper stops Ctrl-C and similar signals from leaving the terminal in + * an unusable state if its settings have been modified when reading user input. + * This can be an issue on non-Windows platforms. + * + * Usage: + * + * $inputHelper = new TerminalInputHelper($inputStream); + * + * ...change terminal settings + * + * // Wait for input before all input reads + * $inputHelper->waitForInput(); + * + * ...read input + * + * // Call finish to restore terminal settings and signal handlers + * $inputHelper->finish() + * + * @internal + */ +final class TerminalInputHelper +{ + /** @var resource */ + private $inputStream; + private bool $isStdin; + private string $initialState; + private int $signalToKill = 0; + private array $signalHandlers = []; + private array $targetSignals = []; + + /** + * @param resource $inputStream + * + * @throws \RuntimeException If unable to read terminal settings + */ + public function __construct($inputStream) + { + if (!\is_string($state = shell_exec('stty -g'))) { + throw new \RuntimeException('Unable to read the terminal settings.'); + } + $this->inputStream = $inputStream; + $this->initialState = $state; + $this->isStdin = 'php://stdin' === stream_get_meta_data($inputStream)['uri']; + $this->createSignalHandlers(); + } + + /** + * Waits for input and terminates if sent a default signal. + */ + public function waitForInput(): void + { + if ($this->isStdin) { + $r = [$this->inputStream]; + $w = []; + + // Allow signal handlers to run, either before Enter is pressed + // when icanon is enabled, or a single character is entered when + // icanon is disabled + while (0 === @stream_select($r, $w, $w, 0, 100)) { + $r = [$this->inputStream]; + } + } + $this->checkForKillSignal(); + } + + /** + * Restores terminal state and signal handlers. + */ + public function finish(): void + { + // Safeguard in case an unhandled kill signal exists + $this->checkForKillSignal(); + shell_exec('stty '.$this->initialState); + $this->signalToKill = 0; + + foreach ($this->signalHandlers as $signal => $originalHandler) { + pcntl_signal($signal, $originalHandler); + } + $this->signalHandlers = []; + $this->targetSignals = []; + } + + private function createSignalHandlers(): void + { + if (!\function_exists('pcntl_async_signals') || !\function_exists('pcntl_signal')) { + return; + } + + pcntl_async_signals(true); + $this->targetSignals = [\SIGINT, \SIGQUIT, \SIGTERM]; + + foreach ($this->targetSignals as $signal) { + $this->signalHandlers[$signal] = pcntl_signal_get_handler($signal); + + pcntl_signal($signal, function ($signal) { + // Save current state, then restore to initial state + $currentState = shell_exec('stty -g'); + shell_exec('stty '.$this->initialState); + $originalHandler = $this->signalHandlers[$signal]; + + if (\is_callable($originalHandler)) { + $originalHandler($signal); + // Handler did not exit, so restore to current state + shell_exec('stty '.$currentState); + + return; + } + + // Not a callable, so SIG_DFL or SIG_IGN + if (\SIG_DFL === $originalHandler) { + $this->signalToKill = $signal; + } + }); + } + } + + private function checkForKillSignal(): void + { + if (\in_array($this->signalToKill, $this->targetSignals, true)) { + // Try posix_kill + if (\function_exists('posix_kill')) { + pcntl_signal($this->signalToKill, \SIG_DFL); + posix_kill(getmypid(), $this->signalToKill); + } + + // Best attempt fallback + exit(128 + $this->signalToKill); + } + } +} diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index a1884ff31..9d57161d3 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -2198,6 +2198,31 @@ public function testSignalableWithEventCommandDoesNotInterruptedOnTermSignals() * @group tty */ public function testSignalableRestoresStty() + { + $params = [__DIR__.'/Fixtures/application_signalable.php']; + $this->runRestoresSttyTest($params, 254, true); + } + + /** + * @group tty + * + * @dataProvider provideTerminalInputHelperOption + */ + public function testTerminalInputHelperRestoresStty(string $option) + { + $params = [__DIR__.'/Fixtures/application_sttyhelper.php', $option]; + $this->runRestoresSttyTest($params, 0, false); + } + + public static function provideTerminalInputHelperOption() + { + return [ + ['--choice'], + ['--hidden'], + ]; + } + + private function runRestoresSttyTest(array $params, int $expectedExitCode, bool $equals) { if (!Terminal::hasSttyAvailable()) { $this->markTestSkipped('stty not available'); @@ -2209,22 +2234,29 @@ public function testSignalableRestoresStty() $previousSttyMode = shell_exec('stty -g'); - $p = new Process(['php', __DIR__.'/Fixtures/application_signalable.php']); + array_unshift($params, 'php'); + $p = new Process($params); $p->setTty(true); $p->start(); for ($i = 0; $i < 10 && shell_exec('stty -g') === $previousSttyMode; ++$i) { - usleep(100000); + usleep(200000); } $this->assertNotSame($previousSttyMode, shell_exec('stty -g')); $p->signal(\SIGINT); - $p->wait(); + $exitCode = $p->wait(); $sttyMode = shell_exec('stty -g'); shell_exec('stty '.$previousSttyMode); $this->assertSame($previousSttyMode, $sttyMode); + + if ($equals) { + $this->assertEquals($expectedExitCode, $exitCode); + } else { + $this->assertNotEquals($expectedExitCode, $exitCode); + } } private function createSignalableApplication(Command $command, ?EventDispatcherInterface $dispatcher): Application diff --git a/Tests/Fixtures/application_signalable.php b/Tests/Fixtures/application_signalable.php index 12cf744ea..9cbdd8e78 100644 --- a/Tests/Fixtures/application_signalable.php +++ b/Tests/Fixtures/application_signalable.php @@ -20,7 +20,7 @@ public function getSubscribedSignals(): array public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false { - exit(0); + exit(254); } }) ->setCode(function(InputInterface $input, OutputInterface $output) { diff --git a/Tests/Fixtures/application_sttyhelper.php b/Tests/Fixtures/application_sttyhelper.php new file mode 100644 index 000000000..5b22c60b0 --- /dev/null +++ b/Tests/Fixtures/application_sttyhelper.php @@ -0,0 +1,37 @@ +setDefinition(new InputDefinition([ + new InputOption('choice', null, InputOption::VALUE_NONE, ''), + new InputOption('hidden', null, InputOption::VALUE_NONE, ''), + ])) + ->setCode(function (InputInterface $input, OutputInterface $output) { + if ($input->getOption('choice')) { + $this->getHelper('question') + ->ask($input, $output, new ChoiceQuestion('😊', ['n'])); + } else { + $question = new Question('😊'); + $question->setHidden(true); + $this->getHelper('question') + ->ask($input, $output, $question); + } + + return 0; + }) + ->run() + +; From 13d3176cf8ad8ced24202844e9f95af11e2959fc Mon Sep 17 00:00:00 2001 From: Patrick Kuijvenhoven Date: Mon, 6 Oct 2025 12:24:54 +0200 Subject: [PATCH 42/45] fixup! [Console] Specify types of interactive question choices --- Question/ChoiceQuestion.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Question/ChoiceQuestion.php b/Question/ChoiceQuestion.php index cdcbcb529..bcfe4dc45 100644 --- a/Question/ChoiceQuestion.php +++ b/Question/ChoiceQuestion.php @@ -26,9 +26,9 @@ class ChoiceQuestion extends Question private string $errorMessage = 'Value "%s" is invalid'; /** - * @param string $question The question to ask to the user - * @param array $choices The list of available choices - * @param string|bool|int|float|null $default The default answer to return + * @param string $question The question to ask to the user + * @param array $choices The list of available choices + * @param string|bool|int|float|null $default The default answer to return */ public function __construct(string $question, array $choices, string|bool|int|float|null $default = null) { @@ -44,7 +44,7 @@ public function __construct(string $question, array $choices, string|bool|int|fl } /** - * @return array + * @return array */ public function getChoices(): array { From cdb80fa5869653c83cfe1a9084a673b6daf57ea7 Mon Sep 17 00:00:00 2001 From: Takashi Kanemoto Date: Tue, 14 Oct 2025 13:29:35 +0900 Subject: [PATCH 43/45] [Console] ensure `SHELL_VERBOSITY` is always restored properly --- Application.php | 83 ++++++++++++++++--------------------------------- 1 file changed, 27 insertions(+), 56 deletions(-) diff --git a/Application.php b/Application.php index 527638298..87b536fb3 100644 --- a/Application.php +++ b/Application.php @@ -186,7 +186,8 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu } } - $prevShellVerbosity = getenv('SHELL_VERBOSITY'); + $empty = new \stdClass(); + $prevShellVerbosity = [$_ENV['SHELL_VERBOSITY'] ?? $empty, $_SERVER['SHELL_VERBOSITY'] ?? $empty, getenv('SHELL_VERBOSITY')]; try { $this->configureIO($input, $output); @@ -228,18 +229,14 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu // SHELL_VERBOSITY is set by Application::configureIO so we need to unset/reset it // to its previous value to avoid one command verbosity to spread to other commands - if (false === $prevShellVerbosity) { - if (\function_exists('putenv')) { - @putenv('SHELL_VERBOSITY'); - } + if ($empty === $_ENV['SHELL_VERBOSITY'] = $prevShellVerbosity[0]) { unset($_ENV['SHELL_VERBOSITY']); + } + if ($empty === $_SERVER['SHELL_VERBOSITY'] = $prevShellVerbosity[1]) { unset($_SERVER['SHELL_VERBOSITY']); - } else { - if (\function_exists('putenv')) { - @putenv('SHELL_VERBOSITY='.$prevShellVerbosity); - } - $_ENV['SHELL_VERBOSITY'] = $prevShellVerbosity; - $_SERVER['SHELL_VERBOSITY'] = $prevShellVerbosity; + } + if (\function_exists('putenv')) { + @putenv('SHELL_VERBOSITY'.(false === ($prevShellVerbosity[2] ?? false) ? '' : '='.$prevShellVerbosity[2])); } } @@ -945,57 +942,31 @@ protected function doRenderThrowable(\Throwable $e, OutputInterface $output): vo */ protected function configureIO(InputInterface $input, OutputInterface $output): void { - if (true === $input->hasParameterOption(['--ansi'], true)) { + if ($input->hasParameterOption(['--ansi'], true)) { $output->setDecorated(true); - } elseif (true === $input->hasParameterOption(['--no-ansi'], true)) { + } elseif ($input->hasParameterOption(['--no-ansi'], true)) { $output->setDecorated(false); } - if (true === $input->hasParameterOption(['--no-interaction', '-n'], true)) { - $input->setInteractive(false); - } - - switch ($shellVerbosity = (int) getenv('SHELL_VERBOSITY')) { - case -2: - $output->setVerbosity(OutputInterface::VERBOSITY_SILENT); - break; - case -1: - $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); - break; - case 1: - $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); - break; - case 2: - $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); - break; - case 3: - $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); - break; - default: - $shellVerbosity = 0; - break; - } + $shellVerbosity = match (true) { + $input->hasParameterOption(['--silent'], true) => -2, + $input->hasParameterOption(['--quiet', '-q'], true) => -1, + $input->hasParameterOption('-vvv', true) || $input->hasParameterOption('--verbose=3', true) || 3 === $input->getParameterOption('--verbose', false, true) => 3, + $input->hasParameterOption('-vv', true) || $input->hasParameterOption('--verbose=2', true) || 2 === $input->getParameterOption('--verbose', false, true) => 2, + $input->hasParameterOption('-v', true) || $input->hasParameterOption('--verbose=1', true) || $input->hasParameterOption('--verbose', true) || $input->getParameterOption('--verbose', false, true) => 1, + default => (int) ($_ENV['SHELL_VERBOSITY'] ?? $_SERVER['SHELL_VERBOSITY'] ?? getenv('SHELL_VERBOSITY')), + }; - if (true === $input->hasParameterOption(['--silent'], true)) { - $output->setVerbosity(OutputInterface::VERBOSITY_SILENT); - $shellVerbosity = -2; - } elseif (true === $input->hasParameterOption(['--quiet', '-q'], true)) { - $output->setVerbosity(OutputInterface::VERBOSITY_QUIET); - $shellVerbosity = -1; - } else { - if ($input->hasParameterOption('-vvv', true) || $input->hasParameterOption('--verbose=3', true) || 3 === $input->getParameterOption('--verbose', false, true)) { - $output->setVerbosity(OutputInterface::VERBOSITY_DEBUG); - $shellVerbosity = 3; - } elseif ($input->hasParameterOption('-vv', true) || $input->hasParameterOption('--verbose=2', true) || 2 === $input->getParameterOption('--verbose', false, true)) { - $output->setVerbosity(OutputInterface::VERBOSITY_VERY_VERBOSE); - $shellVerbosity = 2; - } elseif ($input->hasParameterOption('-v', true) || $input->hasParameterOption('--verbose=1', true) || $input->hasParameterOption('--verbose', true) || $input->getParameterOption('--verbose', false, true)) { - $output->setVerbosity(OutputInterface::VERBOSITY_VERBOSE); - $shellVerbosity = 1; - } - } + $output->setVerbosity(match ($shellVerbosity) { + -2 => OutputInterface::VERBOSITY_SILENT, + -1 => OutputInterface::VERBOSITY_QUIET, + 1 => OutputInterface::VERBOSITY_VERBOSE, + 2 => OutputInterface::VERBOSITY_VERY_VERBOSE, + 3 => OutputInterface::VERBOSITY_DEBUG, + default => ($shellVerbosity = 0) ?: $output->getVerbosity(), + }); - if (0 > $shellVerbosity) { + if (0 > $shellVerbosity || $input->hasParameterOption(['--no-interaction', '-n'], true)) { $input->setInteractive(false); } From c28ad91448f86c5f6d9d2c70f0cf68bf135f252a Mon Sep 17 00:00:00 2001 From: Younes ENNAJI Date: Tue, 4 Nov 2025 01:59:30 +0100 Subject: [PATCH 44/45] [Console] Add missing VERBOSITY_SILENT case in CommandDataCollector --- DataCollector/CommandDataCollector.php | 1 + 1 file changed, 1 insertion(+) diff --git a/DataCollector/CommandDataCollector.php b/DataCollector/CommandDataCollector.php index 6dcac66bb..724af54a0 100644 --- a/DataCollector/CommandDataCollector.php +++ b/DataCollector/CommandDataCollector.php @@ -43,6 +43,7 @@ public function collect(Request $request, Response $response, ?\Throwable $excep 'duration' => $command->duration, 'max_memory_usage' => $command->maxMemoryUsage, 'verbosity_level' => match ($command->output->getVerbosity()) { + OutputInterface::VERBOSITY_SILENT => 'silent', OutputInterface::VERBOSITY_QUIET => 'quiet', OutputInterface::VERBOSITY_NORMAL => 'normal', OutputInterface::VERBOSITY_VERBOSE => 'verbose', From 32d9216a85709f0f6bb0cf6a66073f18336fe99d Mon Sep 17 00:00:00 2001 From: Younes ENNAJI Date: Wed, 12 Nov 2025 18:33:48 +0100 Subject: [PATCH 45/45] [Console] Fix signal handlers not being cleared after command termination --- Application.php | 16 +- SignalRegistry/SignalRegistry.php | 61 ++++++- Tests/ApplicationTest.php | 177 +++++++++++++++++++- Tests/SignalRegistry/SignalRegistryTest.php | 63 +++++++ 4 files changed, 310 insertions(+), 7 deletions(-) diff --git a/Application.php b/Application.php index dd562f0eb..8a5364f4a 100644 --- a/Application.php +++ b/Application.php @@ -1012,12 +1012,16 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI } } + $registeredSignals = false; $commandSignals = $command instanceof SignalableCommandInterface ? $command->getSubscribedSignals() : []; if ($commandSignals || $this->dispatcher && $this->signalsToDispatchEvent) { if (!$this->signalRegistry) { throw new RuntimeException('Unable to subscribe to signal events. Make sure that the "pcntl" extension is installed and that "pcntl_*" functions are not disabled by your php.ini\'s "disable_functions" directive.'); } + $registeredSignals = true; + $this->getSignalRegistry()->pushCurrentHandlers(); + if ($this->dispatcher) { // We register application signals, so that we can dispatch the event foreach ($this->signalsToDispatchEvent as $signal) { @@ -1067,7 +1071,13 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI } if (null === $this->dispatcher) { - return $command->run($input, $output); + try { + return $command->run($input, $output); + } finally { + if ($registeredSignals) { + $this->getSignalRegistry()->popPreviousHandlers(); + } + } } // bind before the console.command event, so the listeners have access to input options/arguments @@ -1097,6 +1107,10 @@ protected function doRunCommand(Command $command, InputInterface $input, OutputI if (0 === $exitCode = $event->getExitCode()) { $e = null; } + } finally { + if ($registeredSignals) { + $this->getSignalRegistry()->popPreviousHandlers(); + } } $event = new ConsoleTerminateEvent($command, $input, $output, $exitCode); diff --git a/SignalRegistry/SignalRegistry.php b/SignalRegistry/SignalRegistry.php index ef2e5f04e..ac8851b06 100644 --- a/SignalRegistry/SignalRegistry.php +++ b/SignalRegistry/SignalRegistry.php @@ -13,8 +13,21 @@ final class SignalRegistry { + /** + * @var array> + */ private array $signalHandlers = []; + /** + * @var array>> + */ + private array $stack = []; + + /** + * @var array + */ + private array $originalHandlers = []; + public function __construct() { if (\function_exists('pcntl_async_signals')) { @@ -24,17 +37,21 @@ public function __construct() public function register(int $signal, callable $signalHandler): void { - if (!isset($this->signalHandlers[$signal])) { - $previousCallback = pcntl_signal_get_handler($signal); + $previous = pcntl_signal_get_handler($signal); + + if (!isset($this->originalHandlers[$signal])) { + $this->originalHandlers[$signal] = $previous; + } - if (\is_callable($previousCallback)) { - $this->signalHandlers[$signal][] = $previousCallback; + if (!isset($this->signalHandlers[$signal])) { + if (\is_callable($previous) && [$this, 'handle'] !== $previous) { + $this->signalHandlers[$signal][] = $previous; } } $this->signalHandlers[$signal][] = $signalHandler; - pcntl_signal($signal, $this->handle(...)); + pcntl_signal($signal, [$this, 'handle']); } public static function isSupported(): bool @@ -54,4 +71,38 @@ public function handle(int $signal): void $signalHandler($signal, $hasNext); } } + + /** + * Pushes the current active handlers onto the stack and clears the active list. + * + * This prepares the registry for a new set of handlers within a specific scope. + * + * @internal + */ + public function pushCurrentHandlers(): void + { + $this->stack[] = $this->signalHandlers; + $this->signalHandlers = []; + } + + /** + * Restores the previous handlers from the stack, making them active. + * + * This also restores the original OS-level signal handler if no + * more handlers are registered for a signal that was just popped. + * + * @internal + */ + public function popPreviousHandlers(): void + { + $popped = $this->signalHandlers; + $this->signalHandlers = array_pop($this->stack) ?? []; + + // Restore OS handler if no more Symfony handlers for this signal + foreach ($popped as $signal => $handlers) { + if (!($this->signalHandlers[$signal] ?? false) && isset($this->originalHandlers[$signal])) { + pcntl_signal($signal, $this->originalHandlers[$signal]); + } + } + } } diff --git a/Tests/ApplicationTest.php b/Tests/ApplicationTest.php index 9d57161d3..639527f2d 100644 --- a/Tests/ApplicationTest.php +++ b/Tests/ApplicationTest.php @@ -824,7 +824,7 @@ public function testSetCatchErrors(bool $catchExceptions) try { $tester->run(['command' => 'boom']); - $this->fail('The exception is not catched.'); + $this->fail('The exception is not caught.'); } catch (\Throwable $e) { $this->assertInstanceOf(\Error::class, $e); $this->assertSame('This is an error.', $e->getMessage()); @@ -2259,6 +2259,181 @@ private function runRestoresSttyTest(array $params, int $expectedExitCode, bool } } + /** + * @requires extension pcntl + */ + public function testSignalHandlersAreCleanedUpAfterCommandRuns() + { + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new SignableCommand(false)); + + $signalRegistry = $application->getSignalRegistry(); + $tester = new ApplicationTester($application); + + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should be empty initially.'); + + $tester->run(['command' => 'signal']); + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should be empty after first run.'); + + $tester->run(['command' => 'signal']); + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry should still be empty after second run.'); + } + + /** + * @requires extension pcntl + */ + public function testSignalHandlersCleanupOnException() + { + $command = new class('signal:exception') extends Command implements SignalableCommandInterface { + public function getSubscribedSignals(): array + { + return [\SIGUSR1]; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + return false; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + throw new \RuntimeException('Test exception'); + } + }; + + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(true); + $application->add($command); + + $signalRegistry = $application->getSignalRegistry(); + $tester = new ApplicationTester($application); + + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Pre-condition: Registry must be empty.'); + + $tester->run(['command' => 'signal:exception']); + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Signal handlers must be cleaned up even on exception.'); + } + + /** + * @requires extension pcntl + */ + public function testNestedCommandsIsolateSignalHandlers() + { + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + + $signalRegistry = $application->getSignalRegistry(); + $self = $this; + + $innerCommand = new class('signal:inner') extends Command implements SignalableCommandInterface { + public $signalRegistry; + public $self; + + public function getSubscribedSignals(): array + { + return [\SIGUSR1]; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + return false; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $handlers = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1); + $this->self->assertCount(1, $handlers, 'Inner command should only see its own handler.'); + $output->write('Inner execute.'); + + return 0; + } + }; + + $outerCommand = new class('signal:outer') extends Command implements SignalableCommandInterface { + public $signalRegistry; + public $self; + + public function getSubscribedSignals(): array + { + return [\SIGUSR1]; + } + + public function handleSignal(int $signal, int|false $previousExitCode = 0): int|false + { + return false; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $handlersBefore = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1); + $this->self->assertCount(1, $handlersBefore, 'Outer command must have its handler registered.'); + + $output->write('Outer pre-run.'); + + $this->getApplication()->find('signal:inner')->run(new ArrayInput([]), $output); + + $output->write('Outer post-run.'); + + $handlersAfter = $this->self->getHandlersForSignal($this->signalRegistry, \SIGUSR1); + $this->self->assertCount(1, $handlersAfter, 'Outer command\'s handler must be restored.'); + $this->self->assertSame($handlersBefore, $handlersAfter, 'Handler stack must be identical after pop.'); + + return 0; + } + }; + + $innerCommand->self = $self; + $innerCommand->signalRegistry = $signalRegistry; + $outerCommand->self = $self; + $outerCommand->signalRegistry = $signalRegistry; + + $application->add($innerCommand); + $application->add($outerCommand); + + $tester = new ApplicationTester($application); + + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Pre-condition: Registry must be empty.'); + $tester->run(['command' => 'signal:outer']); + $this->assertStringContainsString('Outer pre-run.Inner execute.Outer post-run.', $tester->getDisplay()); + + $this->assertCount(0, $this->getHandlersForSignal($signalRegistry, \SIGUSR1), 'Registry must be empty after all commands are finished.'); + } + + /** + * @requires extension pcntl + */ + public function testOriginalHandlerRestoredAfterPop() + { + $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'Pre-condition: Original handler for SIGUSR1 must be SIG_DFL.'); + + $application = new Application(); + $application->setAutoExit(false); + $application->setCatchExceptions(false); + $application->add(new SignableCommand(false)); + + $tester = new ApplicationTester($application); + $tester->run(['command' => 'signal']); + + $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'OS-level handler for SIGUSR1 must be restored to SIG_DFL.'); + + $tester->run(['command' => 'signal']); + $this->assertSame(\SIG_DFL, pcntl_signal_get_handler(\SIGUSR1), 'OS-level handler must remain SIG_DFL after a second run.'); + } + + /** + * Reads the private "signalHandlers" property of the SignalRegistry for assertions. + */ + public function getHandlersForSignal(SignalRegistry $registry, int $signal): array + { + $handlers = (\Closure::bind(fn () => $this->signalHandlers, $registry, SignalRegistry::class))(); + + return $handlers[$signal] ?? []; + } + private function createSignalableApplication(Command $command, ?EventDispatcherInterface $dispatcher): Application { $application = new Application(); diff --git a/Tests/SignalRegistry/SignalRegistryTest.php b/Tests/SignalRegistry/SignalRegistryTest.php index f997f7c1d..77b7e28e7 100644 --- a/Tests/SignalRegistry/SignalRegistryTest.php +++ b/Tests/SignalRegistry/SignalRegistryTest.php @@ -129,4 +129,67 @@ public function testTwoCallbacksForASignalPreviousCallbackFromAnotherRegistry() $this->assertTrue($isHandled1); $this->assertTrue($isHandled2); } + + public function testPushPopIsolatesHandlers() + { + $registry = new SignalRegistry(); + + $signal = \SIGUSR1; + + $handler1 = static function () {}; + $handler2 = static function () {}; + + $registry->pushCurrentHandlers(); + $registry->register($signal, $handler1); + + $this->assertCount(1, $this->getHandlersForSignal($registry, $signal)); + + $registry->pushCurrentHandlers(); + $registry->register($signal, $handler2); + + $this->assertCount(1, $this->getHandlersForSignal($registry, $signal)); + $this->assertSame([$handler2], $this->getHandlersForSignal($registry, $signal)); + + $registry->popPreviousHandlers(); + + $this->assertCount(1, $this->getHandlersForSignal($registry, $signal)); + $this->assertSame([$handler1], $this->getHandlersForSignal($registry, $signal)); + + $registry->popPreviousHandlers(); + + $this->assertCount(0, $this->getHandlersForSignal($registry, $signal)); + } + + public function testRestoreOriginalOnEmptyAfterPop() + { + if (!\extension_loaded('pcntl')) { + $this->markTestSkipped('PCNTL extension required'); + } + + $registry = new SignalRegistry(); + + $signal = \SIGUSR2; + + $original = pcntl_signal_get_handler($signal); + + $handler = static function () {}; + + $registry->pushCurrentHandlers(); + $registry->register($signal, $handler); + + $this->assertNotEquals($original, pcntl_signal_get_handler($signal)); + + $registry->popPreviousHandlers(); + + $this->assertEquals($original, pcntl_signal_get_handler($signal)); + } + + private function getHandlersForSignal(SignalRegistry $registry, int $signal): array + { + $ref = new \ReflectionClass($registry); + $prop = $ref->getProperty('signalHandlers'); + $handlers = $prop->getValue($registry); + + return $handlers[$signal] ?? []; + } }