diff --git a/Application.php b/Application.php index 3d5c48f52..1ea644df0 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; @@ -187,6 +186,8 @@ public function run(?InputInterface $input = null, ?OutputInterface $output = nu } } + $prevShellVerbosity = getenv('SHELL_VERBOSITY'); + try { $this->configureIO($input, $output); @@ -224,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) { @@ -1005,8 +1022,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/Attribute/Argument.php b/Attribute/Argument.php new file mode 100644 index 000000000..e6a94d2f1 --- /dev/null +++ b/Attribute/Argument.php @@ -0,0 +1,110 @@ + + * + * 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; +use Symfony\Component\String\UnicodeString; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +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; + private string $function = ''; + + /** + * Represents a console command definition. + * + * If unset, the `name` value will be inferred from the parameter definition. + * + * @param array|callable(CompletionInput):list $suggestedValues The values used for input completion + */ + public function __construct( + public string $description = '', + public string $name = '', + array|callable $suggestedValues = [], + ) { + $this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues; + } + + /** + * @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; + } + + 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" 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" 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) { + $self->name = (new UnicodeString($name))->kebab(); + } + + $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; + } + + 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->getArgument($this->name); + } +} diff --git a/Attribute/AsCommand.php b/Attribute/AsCommand.php index 6066d7c53..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 @@ -22,12 +24,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/Attribute/Option.php b/Attribute/Option.php new file mode 100644 index 000000000..788353463 --- /dev/null +++ b/Attribute/Option.php @@ -0,0 +1,181 @@ + + * + * 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; +use Symfony\Component\String\UnicodeString; + +#[\Attribute(\Attribute::TARGET_PARAMETER)] +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; + private ?int $mode = null; + private string $typeName = ''; + private bool $allowNull = false; + private string $function = ''; + + /** + * Represents a console command --option 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 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, + array|callable $suggestedValues = [], + ) { + $this->suggestedValues = \is_callable($suggestedValues) ? $suggestedValues(...) : $suggestedValues; + } + + /** + * @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; + } + + 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" of "%s()" must declare a default value.', $name, $self->function)); + } + + if (!$self->name) { + $self->name = (new UnicodeString($name))->kebab(); + } + + $self->default = $parameter->getDefaultValue(); + $self->allowNull = $parameter->allowsNull(); + + if ($type instanceof \ReflectionUnionType) { + return $self->handleUnion($type); + } + + if (!$type instanceof \ReflectionNamedType) { + 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" 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" 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" of "%s()" must either be not-nullable or have a default of null.', $name, $self->function)); + } + + if ('bool' === $self->typeName) { + $self->mode = InputOption::VALUE_NONE; + 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 = 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]])) { + $self->suggestedValues = [$instance, $self->suggestedValues[1]]; + } + + return $self; + } + + /** + * @internal + */ + 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, $default, $suggestedValues); + } + + /** + * @internal + */ + public function resolveValue(InputInterface $input): mixed + { + $value = $input->getOption($this->name); + + if (null === $value && \in_array($this->typeName, self::ALLOWED_UNION_TYPES, true)) { + return true; + } + + if ('array' === $this->typeName && $this->allowNull && [] === $value) { + return null; + } + + if ('bool' !== $this->typeName) { + return $value; + } + + if ($this->allowNull && null === $value) { + return null; + } + + return $value ?? $this->default; + } + + private function handleUnion(\ReflectionUnionType $type): self + { + $types = array_map( + 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" 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" of "%s()" must have a default value of false.', $this->name, $this->function)); + } + + $this->mode = InputOption::VALUE_OPTIONAL; + + return $this; + } +} diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c963568c..9f3ae3d7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,21 @@ 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 + * 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()` + * 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 244a419f2..72a10cf76 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; @@ -49,13 +49,18 @@ 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; + /** + * @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,7 +119,23 @@ 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) { + $this->setHelp($attribute?->help ?? ''); + } + + if (\is_callable($this) && self::class === (new \ReflectionMethod($this, 'execute'))->getDeclaringClass()->name) { + $this->code = new InvokableCommand($this, $this(...)); } $this->configure(); @@ -274,12 +312,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); } /** @@ -311,23 +347,7 @@ 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 = $code; + $this->code = new InvokableCommand($this, $code); return $this; } @@ -395,7 +415,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; } /** @@ -648,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 new file mode 100644 index 000000000..72ff407c8 --- /dev/null +++ b/Command/InvokableCommand.php @@ -0,0 +1,157 @@ + + * + * 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 implements SignalableCommandInterface +{ + private readonly \Closure $code; + private readonly ?SignalableCommandInterface $signalableCommand; + private readonly \ReflectionFunction $reflection; + private bool $triggerDeprecations = false; + + public function __construct( + private readonly Command $command, + callable $code, + ) { + $this->code = $this->getClosure($code); + $this->signalableCommand = $code instanceof SignalableCommandInterface ? $code : null; + $this->reflection = new \ReflectionFunction($this->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 (!\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 \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; + } + + /** + * 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 getClosure(callable $code): \Closure + { + if (!$code instanceof \Closure) { + return $code(...); + } + + $this->triggerDeprecations = true; + + 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 = []; + 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) { + 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 Symfony 8.0.', $parameter->getName())); + + 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()) { + 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]; + } + + 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/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/Command/TraceableCommand.php b/Command/TraceableCommand.php index 9df467b0d..ed11cc29f 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; @@ -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, @@ -88,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); @@ -171,6 +168,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'); diff --git a/DependencyInjection/AddConsoleCommandPass.php b/DependencyInjection/AddConsoleCommandPass.php index f1521602a..562627f4b 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; @@ -38,21 +39,38 @@ 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 (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]); + } else { + $invokableRef = null; + } + + $definition->addTag('container.no_preload'); + + /** @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); @@ -72,6 +90,7 @@ public function process(ContainerBuilder $container): void } $description = $tags[0]['description'] ?? null; + $help = $tags[0]['help'] ?? null; unset($tags[0]); $lazyCommandMap[$commandName] = $id; @@ -88,6 +107,7 @@ public function process(ContainerBuilder $container): void } $description ??= $tag['description'] ?? null; + $help ??= $tag['help'] ?? null; } $definition->addMethodCall('setName', [$commandName]); @@ -100,18 +120,22 @@ public function process(ContainerBuilder $container): void $definition->addMethodCall('setHidden', [true]); } + if ($help && $invokableRef) { + $definition->addMethodCall('setHelp', [str_replace('%', '%%', $help)]); + } + 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)); + 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; } - $description = str_replace('%', '%%', $class::getDefaultDescription() ?? ''); } 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/Formatter/OutputFormatter.php b/Formatter/OutputFormatter.php index eab86976d..c72728b27 100644 --- a/Formatter/OutputFormatter.php +++ b/Formatter/OutputFormatter.php @@ -275,6 +275,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/Helper/Helper.php b/Helper/Helper.php index 503fe5571..46e7e2f58 100644 --- a/Helper/Helper.php +++ b/Helper/Helper.php @@ -93,39 +93,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/Helper/QuestionHelper.php b/Helper/QuestionHelper.php index 8e1591ec1..09b65bbf9 100644 --- a/Helper/QuestionHelper.php +++ b/Helper/QuestionHelper.php @@ -516,12 +516,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/Helper/Table.php b/Helper/Table.php index 9cd28ec77..8c3d0a521 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; @@ -905,6 +908,12 @@ private function cleanup(): void */ private static function initStyles(): array { + $markdown = new TableStyle(); + $markdown + ->setDefaultCrossingChar('|') + ->setDisplayOutsideBorder(false) + ; + $borderless = new TableStyle(); $borderless ->setHorizontalBorderChars('=') @@ -942,6 +951,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/Helper/TreeHelper.php b/Helper/TreeHelper.php new file mode 100644 index 000000000..d188afe98 --- /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 (isset($visited[$currentNode])) { + throw new \LogicException(\sprintf('Cycle detected at node: "%s".', $currentNode->getValue())); + } + $visited[$currentNode] = true; + + $this->output->writeln($node); + } + } +} diff --git a/Helper/TreeNode.php b/Helper/TreeNode.php new file mode 100644 index 000000000..8c35266c1 --- /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->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/Input/ArgvInput.php b/Input/ArgvInput.php index fe25b861a..d7c57f688 100644 --- a/Input/ArgvInput.php +++ b/Input/ArgvInput.php @@ -179,7 +179,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]); } 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 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/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/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/ApplicationTest.php b/Tests/ApplicationTest.php index 4f6e6cb96..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; @@ -196,8 +197,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(); @@ -291,7 +294,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() @@ -829,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()); @@ -1275,7 +1278,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 +1303,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 +1442,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 +1500,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 +1570,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 +1684,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 +1713,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 +1745,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 +1877,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 +1953,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); @@ -2235,6 +2256,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 */ @@ -2404,10 +2460,106 @@ 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; } + + 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 @@ -2494,7 +2646,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 { @@ -2511,7 +2663,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 { @@ -2528,7 +2680,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; @@ -2595,8 +2747,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/Command/CommandTest.php b/Tests/Command/CommandTest.php index 199c0c309..0db3572fc 100644 --- a/Tests/Command/CommandTest.php +++ b/Tests/Command/CommandTest.php @@ -12,11 +12,13 @@ namespace Symfony\Component\Console\Tests\Command; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectUserDeprecationMessageTrait; use Symfony\Component\Console\Application; use Symfony\Component\Console\Attribute\AsCommand; 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; @@ -28,6 +30,8 @@ class CommandTest extends TestCase { + use ExpectUserDeprecationMessageTrait; + protected static string $fixturesPath; public static function setUpBeforeClass(): void @@ -347,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); @@ -393,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; }; } @@ -408,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([]); @@ -427,53 +439,97 @@ 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()); $this->assertSame('desc', $command->getDescription()); + $this->assertSame('help', $command->getHelp()); $this->assertTrue($command->isHidden()); $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->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()); + } + 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->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()); + } + 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->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(); + } + + /** + * @group legacy + */ + public function testDeprecatedNonIntegerReturnTypeFromClosureCode() + { + $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 () {}); + $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; }; } -#[AsCommand(name: 'foo', description: 'desc', hidden: true, aliases: ['f'])] +#[AsCommand(name: 'foo', description: 'desc', hidden: true, aliases: ['f'], help: 'help')] class Php8Command extends Command { } @@ -490,3 +546,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'; + } +} diff --git a/Tests/Command/InvokableCommandTest.php b/Tests/Command/InvokableCommandTest.php new file mode 100644 index 000000000..9fc40809a --- /dev/null +++ b/Tests/Command/InvokableCommandTest.php @@ -0,0 +1,383 @@ + + * + * 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\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 +{ + public function testCommandInputArgumentDefinition() + { + $command = new Command('foo'); + $command->setCode(function ( + #[Argument(name: 'very-first-name')] string $name, + #[Argument] ?string $firstName, + #[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('very-first-name'); + self::assertSame('very-first-name', $nameInputArgument->getName()); + self::assertTrue($nameInputArgument->isRequired()); + + $lastNameInputArgument = $command->getDefinition()->getArgument('first-name'); + self::assertSame('first-name', $lastNameInputArgument->getName()); + self::assertFalse($lastNameInputArgument->isRequired()); + self::assertNull($lastNameInputArgument->getDefault()); + + $lastNameInputArgument = $command->getDefinition()->getArgument('last-name'); + self::assertSame('last-name', $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 = 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'], + #[Option] string|bool $opt = false, + ): int { + return 0; + }); + + $timeoutInputOption = $command->getDefinition()->getOption('idle'); + self::assertSame('idle', $timeoutInputOption->getName()); + self::assertNull($timeoutInputOption->getShortcut()); + self::assertTrue($timeoutInputOption->isValueRequired()); + self::assertFalse($timeoutInputOption->isValueOptional()); + self::assertFalse($timeoutInputOption->isNegatable()); + self::assertNull($timeoutInputOption->getDefault()); + + $typeInputOption = $command->getDefinition()->getOption('type'); + self::assertSame('type', $typeInputOption->getName()); + 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::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::assertTrue($rolesInputOption->isValueRequired()); + self::assertFalse($rolesInputOption->isNegatable()); + 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())); + + $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() + { + $command = new Command('foo'); + $command->setCode(function (#[Argument] object $any) {}); + + $this->expectException(LogicException::class); + + $command->getDefinition(); + } + + public function testInvalidOptionType() + { + $command = new Command('foo'); + $command->setCode(function (#[Option] ?object $any = null) {}); + + $this->expectException(LogicException::class); + + $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'); + $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 + */ + 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): 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()); + } + + 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): 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()); + } + + 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 = '', + #[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; + }); + + $command->run(new ArrayInput($parameters), new NullOutput()); + } + + public static function provideNonBinaryInputOptions(): \Generator + { + 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], + ]; + } + + /** + * @dataProvider provideInvalidOptionDefinitions + */ + public function testInvalidOptionDefinition(callable $code) + { + $command = new Command('foo'); + $command->setCode($code); + + $this->expectException(LogicException::class); + + $command->getDefinition(); + } + + public static function provideInvalidOptionDefinitions(): \Generator + { + yield 'no-default' => [ + function (#[Option] string $a) {}, + ]; + yield 'nullable-bool-default-true' => [ + function (#[Option] ?bool $a = true) {}, + ]; + yield 'nullable-bool-default-false' => [ + function (#[Option] ?bool $a = false) {}, + ]; + yield 'invalid-union-type' => [ + function (#[Option] array|bool $a = false) {}, + ]; + yield 'union-type-cannot-allow-null' => [ + function (#[Option] string|bool|null $a = null) {}, + ]; + yield 'union-type-default-true' => [ + function (#[Option] string|bool $a = true) {}, + ]; + yield 'union-type-default-string' => [ + function (#[Option] string|bool $a = 'foo') {}, + ]; + yield 'nullable-string-not-null-default' => [ + function (#[Option] ?string $a = 'foo') {}, + ]; + yield 'nullable-array-not-null-default' => [ + function (#[Option] ?array $a = []) {}, + ]; + } + + 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']; + } +} 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/DependencyInjection/AddConsoleCommandPassTest.php b/Tests/DependencyInjection/AddConsoleCommandPassTest.php index 639e5091e..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; @@ -176,6 +177,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() @@ -206,7 +208,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 +305,48 @@ 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' => '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()); + } + + 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 @@ -314,7 +358,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 { } @@ -331,3 +375,29 @@ public function __construct() parent::__construct(); } } + +#[AsCommand(name: 'invokable', description: 'Just testing', help: 'The %command.name% help content.')] +class InvokableCommand +{ + 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/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; + } +} 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..cc1bae6ac 100644 --- a/Tests/Fixtures/application_signalable.php +++ b/Tests/Fixtures/application_signalable.php @@ -1,6 +1,5 @@ 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/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)); } } 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], ]; } diff --git a/Tests/Helper/ProgressBarTest.php b/Tests/Helper/ProgressBarTest.php index f7de34179..c0278cc33 100644 --- a/Tests/Helper/ProgressBarTest.php +++ b/Tests/Helper/ProgressBarTest.php @@ -1033,7 +1033,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); @@ -1047,7 +1047,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()) ); @@ -1062,7 +1062,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()) ); @@ -1097,7 +1097,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()) ); } @@ -1186,7 +1186,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()) ); } diff --git a/Tests/Helper/QuestionHelperTest.php b/Tests/Helper/QuestionHelperTest.php index 42da50273..76e40cef0 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)); } /** @@ -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): int { $question = new Question('This is a promptable question'); $question->setValidator(function ($value) use (&$tries) { ++$tries; diff --git a/Tests/Helper/TableTest.php b/Tests/Helper/TableTest.php index ebdfa5a5d..eb85364da 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, ], [ diff --git a/Tests/Helper/TreeHelperTest.php b/Tests/Helper/TreeHelperTest.php new file mode 100644 index 000000000..5d1399b27 --- /dev/null +++ b/Tests/Helper/TreeHelperTest.php @@ -0,0 +1,364 @@ + + * + * 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(\PHP_EOL, $output->fetch()); + } + + public function testRenderSingleNode() + { + $rootNode = new TreeNode('Root'); + $output = new BufferedOutput(); + $tree = TreeHelper::createTree($output, $rootNode); + + $tree->render(); + $this->assertSame("Root\n", self::normalizeLineBreaks($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 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'); + $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", self::normalizeLineBreaks(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('', self::normalizeLineBreaks(trim($output->fetch()))); + } + + private static function normalizeLineBreaks($text) + { + return str_replace(\PHP_EOL, "\n", $text); + } +} diff --git a/Tests/Helper/TreeNodeTest.php b/Tests/Helper/TreeNodeTest.php new file mode 100644 index 000000000..0e80da3bd --- /dev/null +++ b/Tests/Helper/TreeNodeTest.php @@ -0,0 +1,86 @@ + + * + * 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 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'); + + $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..7f5bfedd3 --- /dev/null +++ b/Tests/Helper/TreeStyleTest.php @@ -0,0 +1,231 @@ + + * + * 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, self::normalizeLineBreaks(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, self::normalizeLineBreaks(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, self::normalizeLineBreaks(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, self::normalizeLineBreaks(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, self::normalizeLineBreaks(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); + } + + 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 0b40c7c3f..a3b7ae406 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); @@ -219,4 +314,9 @@ public function testAskAndClearExpectFullSectionCleared() escapeshellcmd(stream_get_contents($output->getStream())) ); } + + private static function normalizeLineBreaks($text) + { + return str_replace(\PHP_EOL, "\n", $text); + } } diff --git a/Tests/Tester/ApplicationTesterTest.php b/Tests/Tester/ApplicationTesterTest.php index f43775179..843f2eac7 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,8 +31,10 @@ protected function setUp(): void $this->application->setAutoExit(false); $this->application->register('foo') ->addArgument('foo') - ->setCode(function ($input, $output) { + ->setCode(function (OutputInterface $output): int { $output->writeln('foo'); + + return 0; }) ; @@ -65,11 +69,13 @@ 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): 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); @@ -91,8 +97,10 @@ public function testErrorOutput() $application->setAutoExit(false); $application->register('foo') ->addArgument('foo') - ->setCode(function ($input, $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 ce0a24b99..d36042038 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,11 @@ 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): 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]); @@ -92,7 +98,11 @@ public function testCommandFromApplication() $application->setAutoExit(false); $command = new Command('foo'); - $command->setCode(function ($input, $output) { $output->writeln('foo'); }); + $command->setCode(function (OutputInterface $output): int { + $output->writeln('foo'); + + return 0; + }); $application->add($command); @@ -112,11 +122,13 @@ 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): 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); @@ -127,6 +139,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))->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.$address.$address.\PHP_EOL, $tester->getDisplay()); + } + public function testCommandWithDefaultInputs() { $questions = [ @@ -137,11 +175,13 @@ 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): 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); @@ -162,12 +202,14 @@ 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): 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); @@ -189,12 +231,14 @@ 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): 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); @@ -214,11 +258,13 @@ public function testSymfonyStyleCommandWithInputs() ]; $command = new Command('foo'); - $command->setCode(function ($input, $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); @@ -233,8 +279,10 @@ public function testErrorOutput() $command = new Command('foo'); $command->addArgument('command'); $command->addArgument('foo'); - $command->setCode(function ($input, $output) { + $command->setCode(function (OutputInterface $output): int { $output->getErrorOutput()->write('foo'); + + return 0; }); $tester = new CommandTester($command); diff --git a/Tests/phpt/alarm/command_exit.phpt b/Tests/phpt/alarm/command_exit.phpt index d3015ad9d..c2cf3edc7 100644 --- a/Tests/phpt/alarm/command_exit.phpt +++ b/Tests/phpt/alarm/command_exit.phpt @@ -7,7 +7,6 @@ Test command that exits use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -19,7 +18,7 @@ while (!file_exists($vendor.'/vendor')) { } require $vendor.'/vendor/autoload.php'; -class MyCommand extends Command implements SignalableCommandInterface +class MyCommand extends Command { protected function initialize(InputInterface $input, OutputInterface $output): void { diff --git a/Tests/phpt/signal/command_exit.phpt b/Tests/phpt/signal/command_exit.phpt index 379476189..e14f80c47 100644 --- a/Tests/phpt/signal/command_exit.phpt +++ b/Tests/phpt/signal/command_exit.phpt @@ -7,7 +7,6 @@ Test command that exits use Symfony\Component\Console\Application; use Symfony\Component\Console\Command\Command; -use Symfony\Component\Console\Command\SignalableCommandInterface; use Symfony\Component\Console\Helper\QuestionHelper; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; @@ -19,7 +18,7 @@ while (!file_exists($vendor.'/vendor')) { } require $vendor.'/vendor/autoload.php'; -class MyCommand extends Command implements SignalableCommandInterface +class MyCommand extends Command { protected function execute(InputInterface $input, OutputInterface $output): int { 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 0ed1bd9af..65d69913a 100644 --- a/composer.json +++ b/composer.json @@ -17,9 +17,10 @@ ], "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" + "symfony/string": "^7.2" }, "require-dev": { "symfony/config": "^6.4|^7.0",