From ba27ae5963bed8d53d1962eb6ab1ec755aad3113 Mon Sep 17 00:00:00 2001 From: Julien Rosset Date: Tue, 24 Feb 2026 15:13:01 +0100 Subject: [PATCH] Validation: add suggestions support (autocompletion) --- .../Validation/TCommandWithValidation.php | 38 +++++----- .../Validators/DirectoryValidator.php | 70 ++++++++++++++++++- .../Validation/Validators/EnumValidator.php | 16 ++++- .../Validation/Validators/FileValidator.php | 8 +++ .../Validation/Validators/IValidator.php | 2 +- .../Validators/IValidatorWithSuggestions.php | 23 ++++++ .../Validation/Validators/ListValidator.php | 10 ++- tests/Commands/Email.php | 2 +- 8 files changed, 146 insertions(+), 23 deletions(-) create mode 100644 src/CliProgram/Validation/Validators/IValidatorWithSuggestions.php diff --git a/src/CliProgram/Validation/TCommandWithValidation.php b/src/CliProgram/Validation/TCommandWithValidation.php index 152ff89..2345b05 100644 --- a/src/CliProgram/Validation/TCommandWithValidation.php +++ b/src/CliProgram/Validation/TCommandWithValidation.php @@ -5,9 +5,13 @@ namespace jrosset\CliProgram\Validation; use Closure; use jrosset\CliProgram\Validation\Validators\InvalidValueException; use jrosset\CliProgram\Validation\Validators\IValidator; +use jrosset\CliProgram\Validation\Validators\IValidatorWithSuggestions; use ReflectionFunction; use Stringable; 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\InvalidArgumentException; use Symfony\Component\Console\Input\InputArgument; use Symfony\Component\Console\Input\InputInterface; @@ -188,12 +192,12 @@ trait TCommandWithValidation { /** * Adds an argument * - * @param string $name The argument name - * @param int|null $mode The argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL - * @param mixed $default The default value (for InputArgument::OPTIONAL mode only) - * @param string $description The argument description - * @param IValidator|null $validator The validator or Null if none - * @param Closure|array|null $suggestedValues The function or list of suggested values when completing ; Null if none + * @param string $name The argument name + * @param int|null $mode The argument mode: InputArgument::REQUIRED or InputArgument::OPTIONAL + * @param mixed $default The default value (for InputArgument::OPTIONAL mode only) + * @param string $description The argument description + * @param IValidator|null $validator The validator or Null if none + * @param null|(string|Suggestion)[]|Closure(CompletionInput,CompletionSuggestions):list $suggestedValues The function or list of suggested values when completing ; Null if none * * @return $this * @@ -210,14 +214,13 @@ trait TCommandWithValidation { $default = static::treatStringableDefaultValue($default); if ($validator !== null) { $default = $validator->getValidDefault($default); + if ($validator instanceof IValidatorWithSuggestions) { + $suggestedValues ??= $validator->getSuggestions(); + } } + $suggestedValues ??= []; - if ($suggestedValues !== null) { - parent::addArgument($name, $mode, $description, $default, $suggestedValues); - } - else { - parent::addArgument($name, $mode, $description, $default); - } + parent::addArgument($name, $mode, $description, $default, $suggestedValues); return $this->setArgumentValidator($name, $validator); } /** @@ -284,14 +287,13 @@ trait TCommandWithValidation { $default = static::treatStringableDefaultValue($default); if ($validator !== null) { $default = $validator->getValidDefault($default); + if ($validator instanceof IValidatorWithSuggestions) { + $suggestedValues ??= $validator->getSuggestions(); + } } + $suggestedValues ??= []; - if ($suggestedValues !== null) { - parent::addOption($name, $shortcut, $mode, $description, $default, $suggestedValues); - } - else { - parent::addOption($name, $shortcut, $mode, $description, $default); - } + parent::addOption($name, $shortcut, $mode, $description, $default, $suggestedValues); return $this->setOptionValidator($name, $validator); } /** diff --git a/src/CliProgram/Validation/Validators/DirectoryValidator.php b/src/CliProgram/Validation/Validators/DirectoryValidator.php index 8c1e631..86f9d3c 100644 --- a/src/CliProgram/Validation/Validators/DirectoryValidator.php +++ b/src/CliProgram/Validation/Validators/DirectoryValidator.php @@ -2,13 +2,17 @@ namespace jrosset\CliProgram\Validation\Validators; +use Closure; +use FilesystemIterator; use InvalidArgumentException; +use SplFileInfo; use Stringable; +use Symfony\Component\Console\Completion\CompletionInput; /** * An argument/option value validator for a directory path */ -class DirectoryValidator implements IValidator { +class DirectoryValidator implements IValidatorWithSuggestions { use TInternalValueValidator; /** @@ -43,12 +47,15 @@ class DirectoryValidator implements IValidator { throw new InvalidArgumentException('The default value must be a string or null'); } + /** @noinspection PhpParamsInspection */ if ($this->getOptions()->contains(FilesystemValidationOption::IsReadable) && !is_readable($default)) { throw new InvalidArgumentException('The default value is not readable'); } + /** @noinspection PhpParamsInspection */ if ($this->getOptions()->contains(FilesystemValidationOption::IsWritable) && !is_writable($default)) { throw new InvalidArgumentException('The default value is not writable'); } + /** @noinspection PhpParamsInspection */ if ($this->getOptions()->contains(FilesystemValidationOption::MustExists) && !file_exists($default)) { throw new InvalidArgumentException('The default value doesn\'t exist'); } @@ -101,4 +108,65 @@ class DirectoryValidator implements IValidator { }; return $this; } + + /** + * @inheritDoc + */ + public function getSuggestions (): Closure|array { + return function (CompletionInput $input): array { + $search = str_replace('\\', '/', $input->getCompletionValue()); + + if (str_ends_with($search, '/')) { + $searchDirectory = $search; + $search = null; + } + else { + $searchDirectory = dirname($search); + $search = basename($search); + } + if (!str_ends_with($searchDirectory, '/')) { + $searchDirectory .= '/'; + } + $searchDirectoryIterator = new FilesystemIterator($searchDirectory); + + $suggestionsHighPriority = []; + $suggestionsLowPriority = []; + + /** @var SplFileInfo $fileInfo */ + foreach ($searchDirectoryIterator as $fileInfo) { + if (!$this->checkSuggestedFile($fileInfo)) { + continue; + } + + if ($search === null) { + $suggestions = &$suggestionsLowPriority; + } + elseif (str_starts_with($fileInfo->getFilename(), $search)) { + $suggestions = &$suggestionsHighPriority; + } + elseif (str_starts_with(mb_strtolower($fileInfo->getFilename()), mb_strtolower($search))) { + $suggestions = &$suggestionsLowPriority; + } + else { + continue; + } + + $suggestions[] = ($searchDirectory === './' ? '' : $searchDirectory) + . $fileInfo->getFilename() + . ($fileInfo->isDir() ? '/' : ''); + } + + return array_merge($suggestionsHighPriority, $suggestionsLowPriority); + }; + } + /** + * Vérifie si un fichier suggéré est valide + * + * @param SplFileInfo $file Le fichier + * + * @return bool Vrai si le fichier est valide + */ + protected function checkSuggestedFile (SplFileInfo $file): bool { + return $file->isDir(); + } } \ No newline at end of file diff --git a/src/CliProgram/Validation/Validators/EnumValidator.php b/src/CliProgram/Validation/Validators/EnumValidator.php index d13cebb..69cf04f 100644 --- a/src/CliProgram/Validation/Validators/EnumValidator.php +++ b/src/CliProgram/Validation/Validators/EnumValidator.php @@ -3,7 +3,9 @@ namespace jrosset\CliProgram\Validation\Validators; use Arrayy\Type\StringCollection; +use Closure; use ReflectionEnum; +use ReflectionEnumUnitCase; use ReflectionException; use UnitEnum; @@ -14,7 +16,7 @@ use UnitEnum; * @template-implements IValidator * @template-implements TInternalValueValidator */ -class EnumValidator extends BasedValidator { +class EnumValidator extends BasedValidator implements IValidatorWithSuggestions { /** * @var ReflectionEnum The enumeration */ @@ -57,4 +59,16 @@ class EnumValidator extends BasedValidator { $enumCase = $this->getInternalValidator()->getValue(); return $this->enum->getCase($enumCase)->getValue(); } + + /** + * @inheritDoc + */ + public function getSuggestions (): Closure|array { + return array_map( + function (ReflectionEnumUnitCase $case): string { + return $case->getName(); + }, + $this->enum->getCases() + ); + } } \ No newline at end of file diff --git a/src/CliProgram/Validation/Validators/FileValidator.php b/src/CliProgram/Validation/Validators/FileValidator.php index 05fd0aa..42cbc6e 100644 --- a/src/CliProgram/Validation/Validators/FileValidator.php +++ b/src/CliProgram/Validation/Validators/FileValidator.php @@ -3,6 +3,7 @@ namespace jrosset\CliProgram\Validation\Validators; use InvalidArgumentException; +use SplFileInfo; /** * An argument/option value validator for a file path @@ -68,4 +69,11 @@ class FileValidator extends DirectoryValidator { $this->extensionsPattern = $extensionsPattern; return $this; } + + /** + * @inheritDoc + */ + protected function checkSuggestedFile (SplFileInfo $file): bool { + return true; + } } \ No newline at end of file diff --git a/src/CliProgram/Validation/Validators/IValidator.php b/src/CliProgram/Validation/Validators/IValidator.php index d83894b..19ecb2c 100644 --- a/src/CliProgram/Validation/Validators/IValidator.php +++ b/src/CliProgram/Validation/Validators/IValidator.php @@ -5,7 +5,7 @@ namespace jrosset\CliProgram\Validation\Validators; /** * An argument/option value validator * - * @template TValue + * @template TValue The Type of the returned value */ interface IValidator { /** diff --git a/src/CliProgram/Validation/Validators/IValidatorWithSuggestions.php b/src/CliProgram/Validation/Validators/IValidatorWithSuggestions.php new file mode 100644 index 0000000..256d0b7 --- /dev/null +++ b/src/CliProgram/Validation/Validators/IValidatorWithSuggestions.php @@ -0,0 +1,23 @@ + + */ +interface IValidatorWithSuggestions extends IValidator { + /** + * The method or list of value suggestions + * + * @return (string|Suggestion)[]|Closure(CompletionInput,CompletionSuggestions):list The method or list of value suggestions + */ + public function getSuggestions (): Closure|array; +} \ No newline at end of file diff --git a/src/CliProgram/Validation/Validators/ListValidator.php b/src/CliProgram/Validation/Validators/ListValidator.php index e6f33db..546871a 100644 --- a/src/CliProgram/Validation/Validators/ListValidator.php +++ b/src/CliProgram/Validation/Validators/ListValidator.php @@ -3,6 +3,7 @@ namespace jrosset\CliProgram\Validation\Validators; use Arrayy\Type\StringCollection; +use Closure; /** * An argument/option value validator based on a list of value @@ -11,7 +12,7 @@ use Arrayy\Type\StringCollection; * @template-implements IValidator * @template-implements TInternalValueValidator */ -class ListValidator implements IValidator { +class ListValidator implements IValidatorWithSuggestions { use TIdenticalValidDefaultValidator; use TInternalValueValidator; @@ -59,4 +60,11 @@ class ListValidator implements IValidator { $this->allowedValues = $allowedValues; return $this; } + + /** + * @inheritDoc + */ + public function getSuggestions (): Closure|array { + return $this->getAllowedValues()->toArray(); + } } diff --git a/tests/Commands/Email.php b/tests/Commands/Email.php index c0d8fbb..7e66c1e 100644 --- a/tests/Commands/Email.php +++ b/tests/Commands/Email.php @@ -13,7 +13,7 @@ class Email extends CommandWithValidation { /** * @inheritDoc */ - protected function configure () { + protected function configure (): void { parent::configure(); $this->addArgument(