mirror of
https://github.com/nextcloud/server.git
synced 2026-10-04 17:58:59 +02:00
wires suggestedValues into both the legacy stecman _completion hook and Symfony's native _complete/completion path, supporting static lists and dynamic (static or instance-method) callables Assisted-by: Claude:claude-sonnet-5 Signed-off-by: Carl Schwan <carl@carlschwan.eu>
381 lines
13 KiB
PHP
381 lines
13 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
namespace OC\Console;
|
|
|
|
use OC\Core\Command\Base;
|
|
use OCP\Console\Attribute\Argument;
|
|
use OCP\Console\Attribute\AsCommand;
|
|
use OCP\Console\Attribute\Option;
|
|
use OCP\Console\ExitCode;
|
|
use OCP\Console\IInput;
|
|
use OCP\Console\IOutput;
|
|
use OCP\Console\ISignalHandler;
|
|
use OCP\Console\OutputFormat;
|
|
use OCP\Defaults;
|
|
use OCP\Server;
|
|
use Override;
|
|
use Psr\Container\ContainerInterface;
|
|
use Stecman\Component\Symfony\Console\BashCompletion\CompletionContext;
|
|
use Symfony\Component\Console\Completion\CompletionInput;
|
|
use Symfony\Component\Console\Input\InputArgument;
|
|
use Symfony\Component\Console\Input\InputInterface;
|
|
use Symfony\Component\Console\Input\InputOption;
|
|
use Symfony\Component\Console\Output\OutputInterface;
|
|
use Symfony\Component\Console\Style\SymfonyStyle;
|
|
|
|
/**
|
|
* @template T
|
|
*/
|
|
class CommandAdapter extends Base {
|
|
/** Union types allowed for an option whose value is itself optional */
|
|
private const OPTION_VALUE_OPTIONAL_UNION_TYPES = ['bool|float', 'bool|int', 'bool|string'];
|
|
|
|
private AsCommand $asCommand;
|
|
|
|
private \ReflectionMethod $reflectionMethod;
|
|
|
|
/** @var array<string, array{arg: Argument, parameter: \ReflectionParameter}> */
|
|
private array $arguments = [];
|
|
|
|
/** @var array<string, array{option: Option, parameter: \ReflectionParameter}> */
|
|
private array $options = [];
|
|
|
|
/**
|
|
* @param class-string<T> $className
|
|
*/
|
|
public function __construct(
|
|
private readonly string $className,
|
|
private readonly ?string $method,
|
|
private readonly ContainerInterface $container,
|
|
) {
|
|
|
|
if ($method !== null) {
|
|
$reflectionMethod = new \ReflectionMethod($className, $method);
|
|
$asCommands = $reflectionMethod->getAttributes(AsCommand::class);
|
|
if ($asCommands === []) {
|
|
throw new \RuntimeException('Missing #[AsCommand] attribute on method: ' . $method . ' from class: ' . $className);
|
|
}
|
|
|
|
$this->asCommand = $asCommands[0]->newInstance();
|
|
} else {
|
|
$reflectionClass = new \ReflectionClass($className);
|
|
$asCommands = $reflectionClass->getAttributes(AsCommand::class);
|
|
if ($asCommands === []) {
|
|
throw new \RuntimeException('Missing #[AsCommand] attribute on class: ' . $className);
|
|
}
|
|
|
|
$this->asCommand = $asCommands[0]->newInstance();
|
|
|
|
$reflectionMethod = new \ReflectionMethod($className, '__invoke');
|
|
}
|
|
|
|
$this->reflectionMethod = $reflectionMethod;
|
|
|
|
foreach ($reflectionMethod->getParameters() as $parameter) {
|
|
$args = $parameter->getAttributes(Argument::class);
|
|
if ($args !== []) {
|
|
/** @var Argument $argument */
|
|
$argument = $args[0]->newInstance();
|
|
if ($argument->name === '') {
|
|
$argument->name = $parameter->getName();
|
|
}
|
|
|
|
$this->arguments[$parameter->getName()] = ['arg' => $argument, 'parameter' => $parameter];
|
|
}
|
|
|
|
$args = $parameter->getAttributes(Option::class);
|
|
if ($args !== []) {
|
|
/** @var Option $option */
|
|
$option = $args[0]->newInstance();
|
|
if ($option->name === '') {
|
|
$option->name = $parameter->getName();
|
|
}
|
|
|
|
$this->options[$parameter->getName()] = ['option' => $option, 'parameter' => $parameter];
|
|
}
|
|
}
|
|
|
|
parent::__construct();
|
|
}
|
|
|
|
#[Override]
|
|
public function configure(): void {
|
|
$this->setHelp('More extensive and thorough documentation may be found at ' . Server::get(Defaults::class)->getDocBaseUrl() . PHP_EOL);
|
|
|
|
if ($this->asCommand->supportsOutputFormat) {
|
|
$this->addOption(
|
|
'output',
|
|
null,
|
|
InputOption::VALUE_OPTIONAL,
|
|
'Output format (plain, json or json_pretty, default is plain)',
|
|
$this->defaultOutputFormat
|
|
);
|
|
}
|
|
|
|
$this->setName($this->asCommand->name);
|
|
|
|
if ($this->asCommand->description) {
|
|
$this->setDescription($this->asCommand->description);
|
|
}
|
|
|
|
foreach ($this->arguments as $argument) {
|
|
/** @var Argument $arg */
|
|
$arg = $argument['arg'];
|
|
$parameter = $argument['parameter'];
|
|
$reflection = new ReflectionMember($parameter);
|
|
$type = $reflection->getType();
|
|
$name = $reflection->getName();
|
|
if (!$type instanceof \ReflectionNamedType) {
|
|
throw new \LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped, Union or Intersection types are not supported for command arguments.', $reflection->getMemberName(), $name, $reflection->getSourceName()));
|
|
}
|
|
$isOptional = $reflection->hasDefaultValue() || $reflection->isNullable() || $reflection->isVariadic();
|
|
$typeName = $type->getName();
|
|
$mode = $isOptional ? InputArgument::OPTIONAL : InputArgument::REQUIRED;
|
|
if ($typeName === 'array' || $reflection->isVariadic()) {
|
|
$mode |= InputArgument::IS_ARRAY;
|
|
}
|
|
$default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null;
|
|
|
|
$this->addArgument($arg->name, $mode, $arg->description, $default, $this->wrapForNativeCompletion($arg->suggestedValues));
|
|
}
|
|
|
|
foreach ($this->options as $option) {
|
|
$parameter = $option['parameter'];
|
|
/** @var Option $option */
|
|
$option = $option['option'];
|
|
$reflection = new ReflectionMember($parameter);
|
|
$type = $reflection->getType();
|
|
$name = $reflection->getName();
|
|
$default = $reflection->hasDefaultValue() ? $reflection->getDefaultValue() : null;
|
|
|
|
if ($type instanceof \ReflectionUnionType) {
|
|
// A union of bool with string/int/float declares an option whose value
|
|
// is itself optional, e.g. "--foo" (true), "--foo=bar" (bar) or omitted (false)
|
|
$typeNames = array_map(
|
|
static fn (\ReflectionType $t) => $t instanceof \ReflectionNamedType ? $t->getName() : null,
|
|
$type->getTypes(),
|
|
);
|
|
sort($typeNames);
|
|
$unionTypeName = implode('|', array_filter($typeNames));
|
|
|
|
if (!\in_array($unionTypeName, self::OPTION_VALUE_OPTIONAL_UNION_TYPES, true)) {
|
|
throw new \LogicException(\sprintf('The union type for option "$%s" of "%s" is not supported as a command option. Only "%s" types are allowed.', $name, $reflection->getSourceName(), implode('", "', self::OPTION_VALUE_OPTIONAL_UNION_TYPES)));
|
|
}
|
|
|
|
if ($default !== false) {
|
|
throw new \LogicException(\sprintf('The option "$%s" of "%s" must have a default value of false.', $name, $reflection->getSourceName()));
|
|
}
|
|
|
|
$this->addOption($option->name, $option->shortcut, InputOption::VALUE_OPTIONAL, $option->description, $default, $this->wrapForNativeCompletion($option->suggestedValues));
|
|
continue;
|
|
}
|
|
|
|
if (!$type instanceof \ReflectionNamedType) {
|
|
throw new \LogicException(\sprintf('The %s "$%s" of "%s" must have a named type. Untyped or Intersection types are not supported for command options.', $reflection->getMemberName(), $name, $reflection->getSourceName()));
|
|
}
|
|
|
|
$allowNull = $reflection->isNullable();
|
|
$typeName = $type->getName();
|
|
|
|
if ($typeName === 'bool' && $allowNull && \in_array($default, [true, false], true)) {
|
|
throw new \LogicException(\sprintf('The option %s "$%s" of "%s" must not be nullable when it has a default boolean value.', $reflection->getMemberName(), $name, $reflection->getSourceName()));
|
|
}
|
|
|
|
if ($allowNull && $default !== null) {
|
|
throw new \LogicException(\sprintf('The option %s "$%s" of "%s" must either be not-nullable or have a default of null.', $reflection->getMemberName(), $name, $reflection->getSourceName()));
|
|
}
|
|
|
|
if ($typeName === 'bool') {
|
|
$mode = InputOption::VALUE_NONE;
|
|
if ($default !== false) {
|
|
$mode |= InputOption::VALUE_NEGATABLE;
|
|
} else {
|
|
$default = null;
|
|
}
|
|
} elseif ($typeName === 'array' || $reflection->isVariadic()) {
|
|
$mode = InputOption::VALUE_REQUIRED | InputOption::VALUE_IS_ARRAY;
|
|
} else {
|
|
$mode = InputOption::VALUE_REQUIRED;
|
|
}
|
|
|
|
$this->addOption($option->name, $option->shortcut, $mode, $option->description, $default, $this->wrapForNativeCompletion($option->suggestedValues));
|
|
}
|
|
}
|
|
|
|
#[Override]
|
|
public function execute(InputInterface $input, OutputInterface $output): int {
|
|
/** @var T $instance */
|
|
$instance = $this->container->get($this->className);
|
|
|
|
$symfonyStyle = new SymfonyStyle($input, $output);
|
|
|
|
$parameters = [];
|
|
foreach ($this->reflectionMethod->getParameters() as $parameter) {
|
|
$name = $parameter->getName();
|
|
|
|
if (isset($this->arguments[$name])) {
|
|
$parameters[] = $input->getArgument($this->arguments[$name]['arg']->name);
|
|
continue;
|
|
}
|
|
|
|
if (isset($this->options[$name])) {
|
|
$value = $input->getOption($this->options[$name]['option']->name);
|
|
if ($value === null && $parameter->getType() instanceof \ReflectionUnionType) {
|
|
// The option was passed without a value, e.g. "--foo"
|
|
$value = true;
|
|
}
|
|
$parameters[] = $value;
|
|
continue;
|
|
}
|
|
|
|
$type = $parameter->getType();
|
|
if ($type instanceof \ReflectionNamedType && $type->getName() === IOutput::class) {
|
|
$parameters[] = new OutputAdapter($output, $input, $symfonyStyle, $this);
|
|
continue;
|
|
}
|
|
|
|
if ($type instanceof \ReflectionNamedType && $type->getName() === IInput::class) {
|
|
$parameters[] = new InputAdapter($input, $symfonyStyle);
|
|
continue;
|
|
}
|
|
|
|
if ($type instanceof \ReflectionNamedType && $type->getName() === ISignalHandler::class) {
|
|
$parameters[] = new SignalHandlerAdapter($this);
|
|
continue;
|
|
}
|
|
|
|
if ($type instanceof \ReflectionNamedType && $type->getName() === OutputFormat::class) {
|
|
$output = OutputFormat::tryFrom($input->getOption('output'));
|
|
if ($output === null) {
|
|
$output = OutputFormat::Plain;
|
|
}
|
|
$parameters[] = $output;
|
|
continue;
|
|
}
|
|
|
|
if ($type instanceof \ReflectionNamedType && $type->getName() === InputInterface::class) {
|
|
// Not documented for some advanced usage
|
|
$parameters[] = $input;
|
|
continue;
|
|
}
|
|
|
|
if ($type instanceof \ReflectionNamedType && $type->getName() === OutputInterface::class) {
|
|
// Not documented for some advanced usage
|
|
$parameters[] = $output;
|
|
continue;
|
|
}
|
|
|
|
throw new \LogicException(\sprintf('Unable to resolve parameter "$%s" of "%s": it is neither an #[Argument], an #[Option], nor an %s, %s, %s or %s.', $name, $this->reflectionMethod->getName(), IOutput::class, IInput::class, ISignalHandler::class, OutputFormat::class));
|
|
}
|
|
|
|
if ($this->method !== null) {
|
|
$result = $instance->{$this->method}(...$parameters);
|
|
} else {
|
|
$result = $instance(...$parameters);
|
|
}
|
|
|
|
return $result instanceof ExitCode ? $result->value : $result;
|
|
}
|
|
|
|
#[Override]
|
|
public function completeOptionValues($optionName, CompletionContext $context) {
|
|
$option = $this->findOption($optionName);
|
|
if ($option !== null && $option->suggestedValues !== []) {
|
|
return $this->resolveSuggestedValues($option->suggestedValues, $context->getCurrentWord());
|
|
}
|
|
|
|
return parent::completeOptionValues($optionName, $context);
|
|
}
|
|
|
|
#[Override]
|
|
public function completeArgumentValues($argumentName, CompletionContext $context) {
|
|
$argument = $this->findArgument($argumentName);
|
|
if ($argument !== null && $argument->suggestedValues !== []) {
|
|
return $this->resolveSuggestedValues($argument->suggestedValues, $context->getCurrentWord());
|
|
}
|
|
|
|
return parent::completeArgumentValues($argumentName, $context);
|
|
}
|
|
|
|
private function findArgument(string $name): ?Argument {
|
|
foreach ($this->arguments as $argument) {
|
|
if ($argument['arg']->name === $name) {
|
|
return $argument['arg'];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private function findOption(string $name): ?Option {
|
|
foreach ($this->options as $option) {
|
|
if ($option['option']->name === $name) {
|
|
return $option['option'];
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Symfony's native completion only understands an actual \Closure, not our
|
|
* callable-array convention, so wrap it to route through resolveSuggestedValues().
|
|
*/
|
|
private function wrapForNativeCompletion(array|\Closure $suggestedValues): array|\Closure {
|
|
if ($suggestedValues === [] || $suggestedValues instanceof \Closure) {
|
|
return $suggestedValues;
|
|
}
|
|
|
|
return fn (CompletionInput $input): array => $this->resolveSuggestedValues($suggestedValues, $input->getCompletionValue());
|
|
}
|
|
|
|
/**
|
|
* A callable array [ClassName::class, 'method'] is called as static, or on an
|
|
* instance resolved from the container if the method isn't static.
|
|
*
|
|
* @return string[]
|
|
*/
|
|
private function resolveSuggestedValues(array|\Closure $suggestedValues, string $currentWord): array {
|
|
if ($suggestedValues instanceof \Closure) {
|
|
return array_values($suggestedValues($currentWord));
|
|
}
|
|
|
|
if (\is_string($suggestedValues[0] ?? null) && \is_string($suggestedValues[1] ?? null) && \method_exists($suggestedValues[0], $suggestedValues[1])) {
|
|
[$class, $method] = $suggestedValues;
|
|
$reflectionMethod = new \ReflectionMethod($class, $method);
|
|
$callable = $reflectionMethod->isStatic() ? [$class, $method] : [$this->container->get($class), $method];
|
|
|
|
return array_values($callable($currentWord));
|
|
}
|
|
|
|
return $suggestedValues;
|
|
}
|
|
|
|
#[Override]
|
|
public function abortIfInterrupted(): void {
|
|
// To make it public
|
|
parent::abortIfInterrupted();
|
|
}
|
|
|
|
#[Override]
|
|
public function writeArrayInOutputFormat(InputInterface $input, OutputInterface $output, iterable $items, string $prefix = ' - '): void {
|
|
// To make it public
|
|
parent::writeArrayInOutputFormat($input, $output, $items, $prefix);
|
|
}
|
|
|
|
#[Override]
|
|
public function writeTableInOutputFormat(InputInterface $input, OutputInterface $output, array $items): void {
|
|
// To make it public
|
|
parent::writeTableInOutputFormat($input, $output, $items);
|
|
}
|
|
|
|
#[Override]
|
|
public function writeStreamingTableInOutputFormat(InputInterface $input, OutputInterface $output, \Iterator $items, int $tableGroupSize): void {
|
|
// To make it public
|
|
parent::writeStreamingTableInOutputFormat($input, $output, $items, $tableGroupSize);
|
|
}
|
|
}
|