From 45a72823afab93a6b36de758e1eb7dbef3d48afa Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Fri, 23 Oct 2026 17:53:51 +0800 Subject: [PATCH] feat: move the `CLI` prompts into a `Prompt` class --- system/CLI/AbstractCommand.php | 10 +- system/CLI/AbstractGeneratorCommand.php | 2 +- system/CLI/CLI.php | 167 ++----------- system/CLI/Prompt.php | 200 ++++++++++++++++ .../Commands/Modern/CustomPromptCommand.php | 55 +++++ tests/system/CLI/AbstractCommandTest.php | 24 ++ tests/system/CLI/CLITest.php | 41 ++++ tests/system/CLI/PromptTest.php | 226 ++++++++++++++++++ user_guide_src/source/changelogs/v4.8.0.rst | 10 + user_guide_src/source/cli/cli_library.rst | 24 +- user_guide_src/source/cli/cli_library/025.php | 15 ++ .../source/cli/cli_modern_commands.rst | 9 +- .../source/installation/upgrade_480.rst | 15 ++ 13 files changed, 646 insertions(+), 152 deletions(-) create mode 100644 system/CLI/Prompt.php create mode 100644 tests/_support/Commands/Modern/CustomPromptCommand.php create mode 100644 tests/system/CLI/PromptTest.php create mode 100644 user_guide_src/source/cli/cli_library/025.php diff --git a/system/CLI/AbstractCommand.php b/system/CLI/AbstractCommand.php index c5c8ed3e6e39..9451c45f005e 100644 --- a/system/CLI/AbstractCommand.php +++ b/system/CLI/AbstractCommand.php @@ -494,6 +494,14 @@ final protected function getOutput(): Output return CLI::getOutput(); } + /** + * Returns a prompt reading through the output of the current run. + */ + protected function getPrompt(): Prompt + { + return new Prompt($this->getOutput()); + } + /** * Configures the command's arguments and options definitions. * @@ -811,7 +819,7 @@ private function promptForMissingArguments(array &$arguments, array &$options): $labels = $this->getArgumentPromptLabels(); foreach ($missing as $name) { - $arguments[] = CLI::prompt($labels[$name] ?? lang('CLI.argumentPrompt', [$name]), null, 'required'); + $arguments[] = $this->getPrompt()->ask($labels[$name] ?? lang('CLI.argumentPrompt', [$name]), null, 'required'); CLI::newLine(); } diff --git a/system/CLI/AbstractGeneratorCommand.php b/system/CLI/AbstractGeneratorCommand.php index b42f7fb90aae..b09b5b2bde8a 100644 --- a/system/CLI/AbstractGeneratorCommand.php +++ b/system/CLI/AbstractGeneratorCommand.php @@ -389,7 +389,7 @@ private function confirmCodeIgniterNamespaceOnce(): bool CLI::write(lang('CLI.generator.usingCINamespace'), 'yellow'); - if ($this->isInteractive() && CLI::prompt(lang('CLI.generator.confirmContinue'), ['y', 'n'], 'required') === 'n') { + if ($this->isInteractive() && $this->getPrompt()->ask(lang('CLI.generator.confirmContinue'), ['y', 'n'], 'required') === 'n') { CLI::write(lang('CLI.generator.cancelOperation'), 'yellow'); return $this->confirmedCodeIgniterNamespace = false; diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 4b7e7d486c3e..1ed1ecb21efe 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -13,7 +13,6 @@ namespace CodeIgniter\CLI; -use CodeIgniter\Exceptions\InvalidArgumentException; use WeakMap; /** @@ -202,6 +201,14 @@ public static function setOutput(Output $output): void self::$output = $output; } + /** + * Returns a prompt reading through the current output. + */ + public static function getPrompt(): Prompt + { + return new Prompt(static::getOutput()); + } + /** * Get input from the shell, using readline or the standard STDIN * @@ -240,53 +247,7 @@ public static function input(?string $prefix = null): string */ public static function prompt(string $field, $options = null, $validation = null): string { - $extraOutput = ''; - $default = ''; - - if (isset($validation) && ! is_array($validation) && ! is_string($validation)) { - throw new InvalidArgumentException('$rules can only be of type string|array'); - } - - if (! is_array($validation)) { - $validation = ($validation !== null) ? explode('|', $validation) : []; - } - - if (is_string($options)) { - $extraOutput = ' [' . static::color($options, 'green') . ']'; - $default = $options; - } - - if (is_array($options) && $options !== []) { - $opts = $options; - $extraOutputDefault = static::color((string) $opts[0], 'green'); - - unset($opts[0]); - - if ($opts === []) { - $extraOutput = $extraOutputDefault; - } else { - $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $opts) . ']'; - $validation[] = 'in_list[' . implode(', ', $options) . ']'; - } - - $default = $options[0]; - } - - $output = static::getOutput(); - $output->setCursorAtLineStart(true); - - // The reader renders the prompt itself, so readline redraws repaint it instead of erasing it. - $prompt = sprintf('%s%s%s: ', $field, trim($field) !== '' ? ' ' : '', $extraOutput); - $input = trim($output->getInputOutput()->input($prompt)); - $input = $input === '' ? (string) $default : $input; - - if ($validation !== []) { - while (! static::validate('"' . trim($field) . '"', $input, $validation)) { - $input = static::prompt($field, $options, $validation); - } - } - - return $input; + return static::getPrompt()->ask($field, $options, $validation); } /** @@ -301,21 +262,7 @@ public static function prompt(string $field, $options = null, $validation = null */ public static function promptByKey($text, array $options, $validation = null): string { - if (is_string($text)) { - $text = [$text]; - } elseif (! is_array($text)) { - throw new InvalidArgumentException('$text can only be of type string|array'); - } - - self::isZeroOptions($options); - - if (($line = array_shift($text)) !== null) { - static::write($line); - } - - self::printKeysAndValues($options); - - return static::prompt(PHP_EOL . array_shift($text), array_keys($options), $validation); + return static::getPrompt()->askByKey($text, $options, $validation); } /** @@ -329,102 +276,26 @@ public static function promptByKey($text, array $options, $validation = null): s */ public static function promptByMultipleKeys(string $text, array $options): array { - self::isZeroOptions($options); - - $extraOutputDefault = static::color('0', 'green'); - $opts = $options; - unset($opts[0]); - - if ($opts === []) { - $extraOutput = $extraOutputDefault; - } else { - $optsKey = array_keys($opts); - $extraOutput = '[' . $extraOutputDefault . ', ' . implode(', ', $optsKey) . ']'; - $extraOutput = 'You can specify multiple values separated by commas.' . PHP_EOL . $extraOutput; - } - - static::write($text); - self::printKeysAndValues($options); - static::newLine(); - - $input = static::prompt($extraOutput); - $input = ($input === '') ? '0' : $input; // 0 is default - - // validation - while (true) { - $pattern = preg_match_all('/^\d+(,\d+)*$/', trim($input)); - - // separate input by comma and convert all to an int[] - $inputToArray = array_map(static fn ($value): int => (int) $value, explode(',', $input)); - // find max from key of $options - $maxOptions = array_key_last($options); - // find max from input - $maxInput = max($inputToArray); - - // return the prompt again if $input contain(s) non-numeric character, except a comma. - // And if max from $options less than max from input, - // it means user tried to access null value in $options - if ($pattern < 1 || $maxOptions < $maxInput) { - static::error('Please select correctly.'); - static::newLine(); - - $input = static::prompt($extraOutput); - $input = ($input === '') ? '0' : $input; - } else { - break; - } - } - - $input = []; - - foreach ($options as $key => $description) { - foreach ($inputToArray as $inputKey) { - if ($key === $inputKey) { - $input[$key] = $description; - } - } - } - - return $input; - } - - /** - * Validation for $options in promptByKey() and promptByMultipleKeys(). Return an error if $options is an empty array. - * - * @param array $options - */ - private static function isZeroOptions(array $options): void - { - if ($options === []) { - throw new InvalidArgumentException('No options to select from were provided'); - } - } - - /** - * Print each key and value one by one - * - * @param array $options - */ - private static function printKeysAndValues(array $options): void - { - // +2 for the square brackets around the key - $keyMaxLength = max(array_map(mb_strwidth(...), array_keys($options))) + 2; - - foreach ($options as $key => $description) { - $name = str_pad(' [' . $key . '] ', $keyMaxLength + 4, ' '); - static::write(static::color($name, 'green') . static::wrap($description, 125, $keyMaxLength + 4)); - } + return static::getPrompt()->askByMultipleKeys($text, $options); } /** * Validate one prompt "field" at a time * + * @deprecated 4.8.0 Override `Prompt::validate()` instead. + * * @param string $field Prompt "field" output * @param string $value Input value * @param list|string $rules Validation rules */ protected static function validate(string $field, string $value, $rules): bool { + @trigger_error(sprintf( + 'Since v4.8.0, "%s()" is deprecated. Override "%s::validate()" instead.', + __METHOD__, + Prompt::class, + ), E_USER_DEPRECATED); + $label = $field; $field = 'temp'; $validation = service('validation', null, false); diff --git a/system/CLI/Prompt.php b/system/CLI/Prompt.php new file mode 100644 index 000000000000..2453afa45bc9 --- /dev/null +++ b/system/CLI/Prompt.php @@ -0,0 +1,200 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Validation\ValidationInterface; + +/** + * Console prompt over an `Output`. + */ +class Prompt +{ + private const MAX_LINE_WIDTH = 125; + + public function __construct( + private readonly Output $output, + private ?ValidationInterface $validation = null, + ) { + } + + /** + * Asks a question and returns the answer, asking again until it passes the validation rules. + * + * @param list|string|null $options A default answer, or a list of allowed answers whose first one is the default + * @param list|string|null $validation Validation rules + */ + public function ask(string $field, array|string|null $options = null, array|string|null $validation = null): string + { + $rules = is_string($validation) ? explode('|', $validation) : ($validation ?? []); + $hint = ''; + $default = ''; + + if (is_string($options)) { + $hint = sprintf(' [%s]', $this->output->color($options, 'green')); + $default = $options; + } elseif ($options !== null && $options !== []) { + $default = (string) $options[0]; + $others = $options; + unset($others[0]); + + if ($others === []) { + $hint = $this->output->color($default, 'green'); + } else { + $hint = sprintf('[%s, %s]', $this->output->color($default, 'green'), implode(', ', $others)); + $rules[] = sprintf('in_list[%s]', implode(', ', $options)); + } + } + + // The reader renders the prompt itself, so readline redraws repaint it instead of erasing it. + $prompt = sprintf('%s%s%s: ', $field, trim($field) !== '' ? ' ' : '', $hint); + + do { + $this->output->setCursorAtLineStart(true); + + $input = trim($this->output->getInputOutput()->input($prompt)); + $input = $input === '' ? $default : $input; + } while ($rules !== [] && ! $this->validate(sprintf('"%s"', trim($field)), $input, $rules)); + + return $input; + } + + /** + * Lists the options by key and returns the chosen key, the first key being the default. + * + * @param list|string $text The text before the list, or the texts before the list and before the answer + * @param array $options + * @param list|string|null $validation Validation rules + * + * @throws InvalidArgumentException + */ + public function askByKey(array|string $text, array $options, array|string|null $validation = null): string + { + if (is_string($text)) { + $text = [$text]; + } + + $this->assertHasOptions($options); + + $line = array_shift($text); + + if ($line !== null) { + $this->output->write($line); + } + + $this->writeKeysAndValues($options); + + return $this->ask(sprintf('%s%s', PHP_EOL, array_shift($text)), array_keys($options), $validation); + } + + /** + * Lists the options by key and returns the options chosen by comma-separated keys, the first key being the default. + * + * @param array $options + * + * @return array + * + * @throws InvalidArgumentException + */ + public function askByMultipleKeys(string $text, array $options): array + { + $this->assertHasOptions($options); + + $default = (string) array_key_first($options); + $others = $options; + unset($others[$default]); + + $field = $this->output->color($default, 'green'); + + if ($others !== []) { + $field = sprintf( + 'You can specify multiple values separated by commas.%s[%s, %s]', + PHP_EOL, + $field, + implode(', ', array_keys($others)), + ); + } + + $this->output->write($text); + $this->writeKeysAndValues($options); + $this->output->newLine(); + + while (true) { + $input = $this->ask($field); + $input = $input === '' ? $default : $input; + $keys = array_map(intval(...), explode(',', $input)); + + if (preg_match('/^\d+(,\d+)*$/', $input) === 1 && array_diff($keys, array_keys($options)) === []) { + return array_intersect_key($options, array_flip($keys)); + } + + $this->output->error('Please select correctly.'); + $this->output->newLine(); + } + } + + /** + * Checks the answer against the rules, writing the first error when it fails. + * + * @param string $field The label used in the error + * @param list|string $rules + */ + protected function validate(string $field, string $value, array|string $rules): bool + { + $this->validation ??= single_service('validation'); + + $this->validation->reset()->setRules([ + 'temp' => ['label' => $field, 'rules' => $rules], + ]); + + if ($this->validation->run(['temp' => $value])) { + return true; + } + + $this->output->error($this->validation->getError('temp')); + + return false; + } + + /** + * @param array $options + * + * @throws InvalidArgumentException + */ + private function assertHasOptions(array $options): void + { + if ($options === []) { + throw new InvalidArgumentException('No options to select from were provided'); + } + } + + /** + * @param array $options + */ + private function writeKeysAndValues(array $options): void + { + $padLeft = max(array_map(mb_strwidth(...), array_keys($options))) + 6; + $width = min(self::MAX_LINE_WIDTH, $this->output->getTerminal()->getWidth()) - $padLeft; + + foreach ($options as $key => $description) { + $lines = explode(PHP_EOL, wordwrap($description, $width, PHP_EOL)); + + $this->output->write(sprintf( + '%s%s', + $this->output->color(str_pad(sprintf(' [%s] ', $key), $padLeft), 'green'), + implode(sprintf('%s%s', PHP_EOL, str_repeat(' ', $padLeft)), $lines), + )); + } + } +} diff --git a/tests/_support/Commands/Modern/CustomPromptCommand.php b/tests/_support/Commands/Modern/CustomPromptCommand.php new file mode 100644 index 000000000000..a5fe055e4dfa --- /dev/null +++ b/tests/_support/Commands/Modern/CustomPromptCommand.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace Tests\Support\Commands\Modern; + +use CodeIgniter\CLI\AbstractCommand; +use CodeIgniter\CLI\Attributes\Command; +use CodeIgniter\CLI\Input\Argument; +use CodeIgniter\CLI\Prompt; +use CodeIgniter\CLI\PromptsForMissingInputInterface; + +#[Command( + name: 'test:custom-prompt', + description: 'Fixture that answers its prompts through a Prompt subclass.', + group: 'Fixtures', +)] +final class CustomPromptCommand extends AbstractCommand implements PromptsForMissingInputInterface +{ + /** + * @var array|string> + */ + public static array $receivedArguments = []; + + protected function configure(): void + { + $this->addArgument(new Argument(name: 'name', required: true)); + } + + protected function getPrompt(): Prompt + { + return new class ($this->getOutput()) extends Prompt { + public function ask(string $field, array|string|null $options = null, array|string|null $validation = null): string + { + return 'from the custom prompt'; + } + }; + } + + protected function execute(array $arguments, array $options): int + { + self::$receivedArguments = $arguments; + + return EXIT_SUCCESS; + } +} diff --git a/tests/system/CLI/AbstractCommandTest.php b/tests/system/CLI/AbstractCommandTest.php index ead18d857f3a..985212941354 100644 --- a/tests/system/CLI/AbstractCommandTest.php +++ b/tests/system/CLI/AbstractCommandTest.php @@ -39,6 +39,7 @@ use ReflectionClass; use Tests\Support\Commands\Modern\AppAboutCommand; use Tests\Support\Commands\Modern\CallsAboutCommand; +use Tests\Support\Commands\Modern\CustomPromptCommand; use Tests\Support\Commands\Modern\HeaderlessCommand; use Tests\Support\Commands\Modern\HiddenCommand; use Tests\Support\Commands\Modern\InteractFixtureCommand; @@ -410,6 +411,29 @@ public function testGetOutputFallsBackToTheFacadeBetweenRuns(): void $this->assertNotSame($output, $getOutput()); } + public function testGetPromptReadsThroughTheOutput(): void + { + $io = new MockInputOutput(); + $io->setInputs(['blue']); + CLI::setInputOutput($io); + + $getPrompt = $this->getPrivateMethodInvoker(new OutputProbeCommand(new Commands()), 'getPrompt'); + + $this->assertSame('blue', $getPrompt()->ask('Color?')); + $this->assertSame('Color? : blue' . PHP_EOL, $io->getOutput()); + } + + public function testOverriddenGetPromptAnswersTheMissingArguments(): void + { + CustomPromptCommand::$receivedArguments = []; + + $command = new CustomPromptCommand(new Commands()); + $command->setInteractive(true); + + $this->assertSame(EXIT_SUCCESS, $command->run([], [])); + $this->assertSame(['name' => 'from the custom prompt'], CustomPromptCommand::$receivedArguments); + } + public function testCallSilentlyForwardsNoInteractionOverrideFalseToChild(): void { $command = new ParentCallsInteractFixtureCommand(new Commands()); diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 4473654ca0ee..1f10c9ea552f 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -20,6 +20,7 @@ use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\PhpStreamWrapper; use CodeIgniter\Test\StreamFilterTrait; +use ErrorException; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\Attributes\RequiresOperatingSystem; @@ -351,6 +352,46 @@ public function testPromptByMultipleKeysPassesPromptTextToInputReader(): void ); } + public function testGetPromptReadsThroughTheOutput(): void + { + $io = new MockInputOutput(); + $io->setInputs(['blue']); + CLI::setInputOutput($io); + + $this->assertSame('blue', CLI::getPrompt()->ask('Color?')); + $this->assertSame('Color? : blue' . PHP_EOL, $io->getOutput()); + } + + public function testValidateIsDeprecatedInFavorOfPromptValidate(): void + { + $this->expectException(ErrorException::class); + $this->expectExceptionMessage('Since v4.8.0, "CodeIgniter\\CLI\\CLI::validate()" is deprecated. Override "CodeIgniter\\CLI\\Prompt::validate()" instead.'); + + $this->getPrivateMethodInvoker(CLI::class, 'validate')('Color', 'red', 'required'); + } + + public function testDeprecatedValidateStillReportsTheError(): void + { + $io = new MockInputOutput(); + CLI::setInputOutput($io); + + $superglobals = service('superglobals'); + $scream = $superglobals->server('CODEIGNITER_SCREAM_DEPRECATIONS'); + $superglobals->unsetServer('CODEIGNITER_SCREAM_DEPRECATIONS'); + + try { + $validate = $this->getPrivateMethodInvoker(CLI::class, 'validate'); + + $this->assertTrue($validate('Color', 'red', 'required')); + $this->assertFalse($validate('Color', '', 'required')); + $this->assertStringContainsString('The Color field is required.', $io->getOutput()); + } finally { + if ($scream !== null) { + $superglobals->setServer('CODEIGNITER_SCREAM_DEPRECATIONS', $scream); + } + } + } + public function testInputWritesPrefixToStdout(): void { $io = new MockInputOutput(); diff --git a/tests/system/CLI/PromptTest.php b/tests/system/CLI/PromptTest.php new file mode 100644 index 000000000000..50251ecda867 --- /dev/null +++ b/tests/system/CLI/PromptTest.php @@ -0,0 +1,226 @@ + + * + * For the full copyright and license information, please view + * the LICENSE file that was distributed with this source code. + */ + +namespace CodeIgniter\CLI; + +use CodeIgniter\Exceptions\InvalidArgumentException; +use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; +use CodeIgniter\Validation\ValidationInterface; +use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\Group; + +/** + * @internal + */ +#[CoversClass(Prompt::class)] +#[Group('Others')] +final class PromptTest extends CIUnitTestCase +{ + private MockInputOutput $io; + + protected function setUp(): void + { + parent::setUp(); + + CLI::reset(); + + $this->io = new MockInputOutput(); + } + + protected function tearDown(): void + { + CLI::reset(); + + parent::tearDown(); + } + + /** + * @param list $inputs + */ + private function createPrompt(array $inputs, ?ValidationInterface $validation = null): Prompt + { + $this->io->setInputs($inputs); + + return new Prompt(new Output($this->io), $validation); + } + + private function getUndecoratedOutput(): string + { + return preg_replace('/\e\[[^m]+m/', '', $this->io->getOutput()) ?? ''; + } + + public function testAskReturnsTheAnswer(): void + { + $this->assertSame('red', $this->createPrompt(['red'])->ask('Color?')); + $this->assertSame("Color? : red\n", $this->getUndecoratedOutput()); + } + + public function testAskReturnsTheDefaultForAnEmptyAnswer(): void + { + $this->assertSame('red', $this->createPrompt([''])->ask('Color?', 'red')); + $this->assertSame("Color? [red]: \n", $this->getUndecoratedOutput()); + } + + public function testAskKeepsAZeroAnswer(): void + { + $this->assertSame('0', $this->createPrompt(['0'])->ask('Number?', '7')); + } + + public function testAskWithASingleOptionShowsItAsTheDefault(): void + { + $this->assertSame('red', $this->createPrompt([''])->ask('Color?', ['red'])); + $this->assertSame("Color? red: \n", $this->getUndecoratedOutput()); + } + + public function testAskAsksAgainUntilTheAnswerIsAnOption(): void + { + $this->assertSame('blue', $this->createPrompt(['green', 'blue'])->ask('Color?', ['red', 'blue'])); + $this->assertSame( + "Color? [red, blue]: green\nThe \"Color?\" field must be one of: red, blue.\nColor? [red, blue]: blue\n", + $this->getUndecoratedOutput(), + ); + } + + public function testAskAsksAgainUntilTheAnswerPassesTheRules(): void + { + $this->assertSame('a@example.com', $this->createPrompt(['nope', 'a@example.com'])->ask('Email', null, ['valid_email'])); + $this->assertStringContainsString('The "Email" field must contain a valid email address.', $this->getUndecoratedOutput()); + } + + public function testAskUsesTheInjectedValidation(): void + { + $validation = $this->createMock(ValidationInterface::class); + $validation->method('reset')->willReturnSelf(); + $validation->method('setRules')->willReturnSelf(); + $validation->expects($this->exactly(2))->method('run')->willReturnOnConsecutiveCalls(false, true); + $validation->method('getError')->willReturn('Not today.'); + + $this->assertSame('blue', $this->createPrompt(['red', 'blue'], $validation)->ask('Color?', null, 'required')); + $this->assertStringContainsString("Not today.\n", $this->getUndecoratedOutput()); + } + + public function testAskByKeyListsTheOptionsAndReturnsTheKey(): void + { + $this->assertSame('1', $this->createPrompt(['1'])->askByKey('Hobby:', ['Games', 'Sleep'])); + $this->assertSame( + "\nHobby:\n [0] Games\n [1] Sleep\n\n[0, 1]: 1\n", + $this->getUndecoratedOutput(), + ); + } + + public function testAskByKeyReturnsTheFirstKeyForAnEmptyAnswer(): void + { + $this->assertSame('0', $this->createPrompt([''])->askByKey('Hobby:', ['Games', 'Sleep'])); + } + + public function testAskByKeyTakesTheTextsBeforeTheListAndTheAnswer(): void + { + $this->assertSame('b', $this->createPrompt(['b'])->askByKey(['Pick a hobby.', 'Hobby'], ['a' => 'Games', 'b' => 'Sleep'])); + $this->assertSame( + "\nPick a hobby.\n [a] Games\n [b] Sleep\n\nHobby [a, b]: b\n", + $this->getUndecoratedOutput(), + ); + } + + public function testAskByKeyRejectsAnEmptyList(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No options to select from were provided'); + + $this->createPrompt([])->askByKey('Hobby:', []); + } + + public function testAskByMultipleKeysReturnsTheChosenOptions(): void + { + $this->assertSame( + [0 => 'Games', 2 => 'Music'], + $this->createPrompt(['0,2'])->askByMultipleKeys('Hobbies:', ['Games', 'Sleep', 'Music']), + ); + $this->assertSame( + "\nHobbies:\n [0] Games\n [1] Sleep\n [2] Music\n\nYou can specify multiple values separated by commas.\n[0, 1, 2] : 0,2\n", + $this->getUndecoratedOutput(), + ); + } + + public function testAskByMultipleKeysReturnsTheFirstOptionForAnEmptyAnswer(): void + { + $this->assertSame([0 => 'Games'], $this->createPrompt([''])->askByMultipleKeys('Hobbies:', ['Games', 'Sleep'])); + } + + public function testAskByMultipleKeysWithOneOptionShowsOnlyTheDefault(): void + { + $this->assertSame([0 => 'Games'], $this->createPrompt(['0'])->askByMultipleKeys('Hobbies:', ['Games'])); + $this->assertStringContainsString("\n0 : 0\n", $this->getUndecoratedOutput()); + } + + public function testAskByMultipleKeysAsksAgainForAnInvalidOrUnknownKey(): void + { + $this->assertSame( + [1 => 'Sleep'], + $this->createPrompt(['x', '5', '1'])->askByMultipleKeys('Hobbies:', ['Games', 'Sleep']), + ); + $this->assertSame(2, substr_count($this->getUndecoratedOutput(), "Please select correctly.\n")); + } + + public function testAskByMultipleKeysAsksAgainForAMissingKeyBelowTheLast(): void + { + $this->assertSame( + [2 => 'Music'], + $this->createPrompt(['1', '2'])->askByMultipleKeys('Hobbies:', [0 => 'Games', 2 => 'Music']), + ); + $this->assertSame(1, substr_count($this->getUndecoratedOutput(), "Please select correctly.\n")); + } + + public function testAskByMultipleKeysAcceptsAKeyAboveTheLast(): void + { + $this->assertSame( + [5 => 'Sleep'], + $this->createPrompt(['5'])->askByMultipleKeys('Hobbies:', [0 => 'Games', 5 => 'Sleep', 2 => 'Music']), + ); + $this->assertStringNotContainsString('Please select correctly.', $this->getUndecoratedOutput()); + } + + public function testAskByMultipleKeysDefaultsToTheFirstKey(): void + { + $this->assertSame( + [2 => 'Music'], + $this->createPrompt([''])->askByMultipleKeys('Hobbies:', [2 => 'Music', 5 => 'Sleep']), + ); + $this->assertStringContainsString("[2, 5] : \n", $this->getUndecoratedOutput()); + $this->assertStringNotContainsString('Please select correctly.', $this->getUndecoratedOutput()); + } + + public function testOptionsWrapToTheWidthOfTheOutputTerminal(): void + { + $terminal = $this->createStub(Terminal::class); + $terminal->method('getWidth')->willReturn(30); + + $this->io->setInputs(['']); + $prompt = new Prompt(new Output($this->io, $terminal)); + + $this->assertSame('0', $prompt->askByKey('Pick:', ['alpha beta gamma delta epsilon zeta'])); + $this->assertStringContainsString( + " [0] alpha beta gamma delta\n epsilon zeta\n", + $this->getUndecoratedOutput(), + ); + } + + public function testAskByMultipleKeysRejectsAnEmptyList(): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('No options to select from were provided'); + + $this->createPrompt([])->askByMultipleKeys('Hobbies:', []); + } +} diff --git a/user_guide_src/source/changelogs/v4.8.0.rst b/user_guide_src/source/changelogs/v4.8.0.rst index e3e116d1f996..27a548bec9df 100644 --- a/user_guide_src/source/changelogs/v4.8.0.rst +++ b/user_guide_src/source/changelogs/v4.8.0.rst @@ -41,6 +41,10 @@ Behavior Changes - **CLI:** The output state moved out of the static ``CLI`` properties. A subclass of ``CLI`` that overrides ``fwrite()`` or ``color()``, or reads ``$lastWrite``, ``$isColored``, ``$io``, ``$height``, ``$width``, or the colour tables, no longer affects or observes the output. See :ref:`upgrade-480-cli-output-classes`. +- **CLI:** The prompt methods moved to ``Prompt``. A subclass of ``CLI`` that overrides ``validate()`` no longer affects the prompts, and + an override of ``prompt()`` no longer affects ``promptByKey()`` or ``promptByMultipleKeys()``. Passing ``prompt()`` options or rules, or a + ``promptByKey()`` text, of another type than ``string``, ``array``, or ``null`` now throws a ``TypeError`` instead of an ``InvalidArgumentException``. + See :ref:`upgrade-480-cli-prompt`. - **Commands:** The ``-h`` option to ``routes`` command which was mapped previously to the ``--sort-by-handler`` option is now removed. Use ``--sort-by-handler`` instead to sort the routes by handler when running the ``routes`` command. - **Commands:** The ``filter:check`` command now requires the HTTP method argument to be uppercase (e.g., ``spark filter:check GET /`` instead of ``spark filter:check get /``). @@ -267,6 +271,10 @@ Commands - Modern commands can write through ``$this->getOutput()``, the same ``Output`` used by the static ``CLI`` methods. ``AbstractCommand::run()`` and ``Commands::runCommand()`` accept an ``Output`` to run against, which ``Console`` passes down and sub-commands inherit. See :ref:`modern-commands-writing-output`. +- The prompt methods of ``CLI`` now delegate to :php:class:`Prompt ` (``ask()``, ``askByKey()``, + ``askByMultipleKeys()``), which reads through an ``Output`` and accepts the validation that checks the answers. + ``CLI::getPrompt()`` and ``AbstractCommand::getPrompt()`` return one on the shared output and on the output of the run. + See :ref:`cli-library-classes`. - Added ``--silent``, ``--quiet`` / ``-q``, and ``--verbose`` / ``-v``, ``-vv``, ``-vvv`` to every command, with ``SHELL_VERBOSITY`` as the fallback. Modern commands can write at a :php:class:`Verbosity ` through ``$this->getOutput()``. ``lang:find`` uses the global ``--verbose`` in place of its own. See :ref:`spark-verbosity` and :ref:`modern-commands-verbosity`. - Added :php:class:`AbstractGeneratorCommand ` and the ``#[GeneratorCommand]`` attribute, the modern @@ -470,6 +478,8 @@ Deprecations - **CLI:** The ``CLI::$foreground_colors``, ``CLI::$background_colors``, ``CLI::$lastWrite``, ``CLI::$height``, ``CLI::$width``, ``CLI::$isColored``, ``CLI::$io``, and ``CLI::$stdoutToStderr`` properties are deprecated and no longer used. Their state lives in ``Color``, ``Output``, and ``Terminal``. See :ref:`upgrade-480-cli-output-classes`. +- **CLI:** ``CLI::validate()`` is deprecated and no longer used by the prompts. Calling it triggers a deprecation notice. Override ``Prompt::validate()`` instead. + See :ref:`upgrade-480-cli-prompt`. - **HTTP:** The ``CLIRequest::parseCommand()`` method is now deprecated and will be removed in a future release. The ``CLIRequest`` class now uses the new ``CommandLineParser`` class to handle command-line argument parsing. - **HTTP:** ``URI::setSilent()`` is now hard deprecated. This method was only previously marked as deprecated. It will now trigger a deprecation notice when used. diff --git a/user_guide_src/source/cli/cli_library.rst b/user_guide_src/source/cli/cli_library.rst index 2a93a0166a2b..7c4cbde797f6 100644 --- a/user_guide_src/source/cli/cli_library.rst +++ b/user_guide_src/source/cli/cli_library.rst @@ -283,7 +283,13 @@ modern commands reach the output through ``$this->getOutput()`` .. literalinclude:: cli_library/024.php Mixing the static methods with the instance is safe, because both write to the same -``Output`` and share its line breaks. The prompt methods remain static on ``CLI``. +``Output`` and share its line breaks. + +The prompt methods delegate to :php:class:`Prompt`, which reads through an ``Output``. +``CLI::getPrompt()`` returns one on the shared output, and modern commands reach one on the +output of their run through ``$this->getPrompt()``: + +.. literalinclude:: cli_library/025.php .. php:namespace:: CodeIgniter\CLI @@ -338,6 +344,22 @@ Mixing the static methods with the instance is safe, because both write to the s .. php:method:: getTerminal(): Terminal +.. php:class:: Prompt + + Asks questions on the console, reading the answers through an ``Output``. + + .. php:method:: __construct(Output $output[, ?ValidationInterface $validation = null]) + + :param Output $output: The output the questions are written and read through. + :param ValidationInterface|null $validation: The validation that checks the answers, or ``null`` for an + unshared instance of the ``validation`` service. + + .. php:method:: ask(string $field[, array|string|null $options = null[, array|string|null $validation = null]]): string + .. php:method:: askByKey(array|string $text, array $options[, array|string|null $validation = null]): string + .. php:method:: askByMultipleKeys(string $text, array $options): array + + The same as ``CLI::prompt()``, ``CLI::promptByKey()``, and ``CLI::promptByMultipleKeys()``. + .. php:class:: Verbosity The level of the output, as an ``int``-backed enum valued as ``SHELL_VERBOSITY``: diff --git a/user_guide_src/source/cli/cli_library/025.php b/user_guide_src/source/cli/cli_library/025.php new file mode 100644 index 000000000000..e993fe5d9320 --- /dev/null +++ b/user_guide_src/source/cli/cli_library/025.php @@ -0,0 +1,15 @@ +ask('What is your favorite color?', ['red', 'blue']); +$hobby = $prompt->askByKey('Pick a hobby:', ['Games', 'Sleep']); +$hobbies = $prompt->askByMultipleKeys('Pick your hobbies:', ['Games', 'Sleep', 'Music']); + +// A prompt of your own, here with a validation instance of your choice. +$prompt = new Prompt(CLI::getOutput(), single_service('validation')); +$email = $prompt->ask('What is your email?', null, 'required|valid_email'); diff --git a/user_guide_src/source/cli/cli_modern_commands.rst b/user_guide_src/source/cli/cli_modern_commands.rst index b04f1800aa09..5477dffb02f7 100644 --- a/user_guide_src/source/cli/cli_modern_commands.rst +++ b/user_guide_src/source/cli/cli_modern_commands.rst @@ -523,7 +523,7 @@ definitions rather than class properties and ad-hoc lookups. ``showHelp()`` override Gone. The built-in ``help`` command builds the help output itself from the declared arguments, options, and usages. -Prompting the user mid-run stays with ``CLI::prompt()``, but the idiomatic spot moves from ``run()`` +Prompting the user goes through ``$this->getPrompt()``, and the idiomatic spot moves from ``run()`` to ``interact()`` so validation can see whatever the user provides interactively. A typical ``BaseCommand`` implementation: @@ -735,6 +735,13 @@ covered in the sections above and are not listed here. which is the same instance written to by the static ``CLI`` methods. See :ref:`modern-commands-writing-output`. + .. php:method:: getPrompt(): Prompt + + Returns a :php:class:`Prompt ` reading through the + output of the current run. See :ref:`cli-library-classes`. Override it to return a + ``Prompt`` subclass, which then also asks for missing arguments (see + :ref:`prompts-for-missing-input`) and the generators' confirmation. + .. php:method:: call(string $command[, array $arguments = [], array $options = [], ?bool $noInteractionOverride = null]): int :param string $command: The name of the modern command to call. diff --git a/user_guide_src/source/installation/upgrade_480.rst b/user_guide_src/source/installation/upgrade_480.rst index c71011e6bc9c..3c8797ecec22 100644 --- a/user_guide_src/source/installation/upgrade_480.rst +++ b/user_guide_src/source/installation/upgrade_480.rst @@ -84,6 +84,21 @@ colour or terminal override with an ``Output`` or ``Terminal`` subclass passed t Tests that reached those properties through reflection should use the instances returned by ``CLI::getOutput()`` and ``CLI::getTerminal()`` instead. +.. _upgrade-480-cli-prompt: + +CLI Prompt +========== + +``CLI::prompt()``, ``CLI::promptByKey()``, and ``CLI::promptByMultipleKeys()`` now delegate to +the ``Prompt`` class, and ask and answer exactly as before. + +If you extend ``CLI`` and override ``validate()``, the prompts no longer call it. Extend ``Prompt`` +and override its ``validate()`` instead, then return your subclass from ``getPrompt()``: the static +``CLI::getPrompt()`` in your ``CLI`` subclass, or ``AbstractCommand::getPrompt()`` in a modern command. +To change only the validation, pass a validation instance of your own to the ``Prompt`` constructor. +An override of ``CLI::prompt()`` is still reached by calls to ``CLI::prompt()``, but no longer by +``promptByKey()`` or ``promptByMultipleKeys()``. + Uploaded File Move Return Type ==============================