From dfd88c2a106e2823fea4bbaecdead992b3c15a58 Mon Sep 17 00:00:00 2001 From: "John Paul E. Balandan, CPA" Date: Sun, 30 Aug 2026 20:16:25 +0800 Subject: [PATCH] fix: pass prompt text to readline in `CLI::prompt()` so backspace does not erase it --- system/CLI/CLI.php | 8 +- system/CLI/InputOutput.php | 20 +++- tests/system/CLI/CLITest.php | 124 ++++++++++++++++++++ user_guide_src/source/changelogs/v4.7.5.rst | 2 + 4 files changed, 148 insertions(+), 6 deletions(-) diff --git a/system/CLI/CLI.php b/system/CLI/CLI.php index 037eaf878ebb..08d5c214582e 100644 --- a/system/CLI/CLI.php +++ b/system/CLI/CLI.php @@ -254,12 +254,12 @@ public static function prompt(string $field, $options = null, $validation = null $default = $options[0]; } - static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': '); static::$lastWrite = 'write'; - // Read the input from keyboard. - $input = trim(static::$io->input()); - $input = ($input === '') ? (string) $default : $input; + // 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(static::$io->input($prompt)); + $input = $input === '' ? (string) $default : $input; if ($validation !== []) { while (! static::validate('"' . trim($field) . '"', $input, $validation)) { diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php index 6ed3b8f36138..1d99563dd0ea 100644 --- a/system/CLI/InputOutput.php +++ b/system/CLI/InputOutput.php @@ -43,10 +43,18 @@ public function input(?string $prefix = null): string { // readline() can't be tested. if ($this->readlineSupport && ENVIRONMENT !== 'testing') { - return readline($prefix); // @codeCoverageIgnore + // @codeCoverageIgnoreStart + // Libedit reports "EditLine wrapper" and mangles the markers, so only GNU readline gets them. + if ($prefix !== null && ! str_contains(readline_info('library_version'), 'EditLine')) { + $prefix = $this->markAnsiNonPrinting($prefix); + } + + return readline($prefix); + // @codeCoverageIgnoreEnd } - echo $prefix; + // self:: skips MockInputOutput's fwrite override, whose filter bookkeeping must not nest inside input(). + self::fwrite(STDOUT, $prefix ?? ''); $input = fgets(fopen('php://stdin', 'rb')); @@ -77,4 +85,12 @@ public function fwrite($handle, string $string): void fwrite($handle, $string); } + + /** + * Wraps ANSI escape sequences in readline's non-printing markers so line-redraw column accounting skips them. + */ + private function markAnsiNonPrinting(string $prefix): string + { + return preg_replace('/(\e\[[0-9;]*m)/', "\x01\$1\x02", $prefix); + } } diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index 052fbd602aea..f4db06f17102 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -17,6 +17,7 @@ use CodeIgniter\Exceptions\RuntimeException; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; +use CodeIgniter\Test\Mock\MockInputOutput; use CodeIgniter\Test\PhpStreamWrapper; use CodeIgniter\Test\StreamFilterTrait; use PHPUnit\Framework\Attributes\DataProvider; @@ -143,6 +144,129 @@ public function testPromptInputZero(): void $this->assertSame('0', $output); } + public function testPromptPassesPromptTextToInputReader(): void + { + $io = new class () extends InputOutput { + public ?string $receivedPrefix = null; + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return 'red'; + } + }; + CLI::setInputOutput($io); + + $output = CLI::prompt('What is your favorite color?'); + + CLI::resetInputOutput(); + + $this->assertSame('red', $output); + $this->assertSame('What is your favorite color? : ', $io->receivedPrefix); + } + + public function testPromptPassesDefaultOptionInPromptText(): void + { + $io = new class () extends InputOutput { + public ?string $receivedPrefix = null; + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return ''; + } + }; + CLI::setInputOutput($io); + + $output = CLI::prompt('What is your favorite color?', 'red'); + + CLI::resetInputOutput(); + + $this->assertSame('red', $output); + $this->assertSame( + sprintf('What is your favorite color? [%s]: ', CLI::color('red', 'green')), + $io->receivedPrefix, + ); + } + + public function testPromptByKeyPassesPromptTextToInputReader(): void + { + $io = new class () extends InputOutput { + public ?string $receivedPrefix = null; + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return '1'; + } + }; + CLI::setInputOutput($io); + + $output = CLI::promptByKey('Select your hobbies:', ['Playing game', 'Sleep', 'Badminton']); + + CLI::resetInputOutput(); + + $this->assertSame('1', $output); + $this->assertSame( + PHP_EOL . sprintf('[%s, 1, 2]: ', CLI::color('0', 'green')), + $io->receivedPrefix, + ); + } + + public function testPromptByMultipleKeysPassesPromptTextToInputReader(): void + { + $io = new class () extends InputOutput { + public ?string $receivedPrefix = null; + + public function input(?string $prefix = null): string + { + $this->receivedPrefix = $prefix; + + return '0,1'; + } + }; + CLI::setInputOutput($io); + + $output = CLI::promptByMultipleKeys('Select your hobbies:', ['Playing game', 'Sleep', 'Badminton']); + + CLI::resetInputOutput(); + + $this->assertSame([0 => 'Playing game', 1 => 'Sleep'], $output); + $this->assertSame( + 'You can specify multiple values separated by commas.' . PHP_EOL + . sprintf('[%s, 1, 2] : ', CLI::color('0', 'green')), + $io->receivedPrefix, + ); + } + + public function testInputWritesPrefixToStdout(): void + { + $io = new MockInputOutput(); + $io->setInputs(['blue']); + CLI::setInputOutput($io); + + $output = CLI::input('Name: '); + + CLI::resetInputOutput(); + + $this->assertSame('blue', $output); + $this->assertSame('Name: blue' . PHP_EOL, $io->getOutput()); + } + + public function testMarkAnsiNonPrintingWrapsEscapeSequences(): void + { + $wrap = $this->getPrivateMethodInvoker(new InputOutput(), 'markAnsiNonPrinting'); + + $this->assertSame( + "What is your favorite color? [\x01\e[0;32m\x02red\x01\e[0m\x02]: ", + $wrap(sprintf('What is your favorite color? [%s]: ', CLI::color('red', 'green'))), + ); + $this->assertSame('Name: ', $wrap('Name: ')); + } + public function testPromptByKey(): void { PhpStreamWrapper::register(); diff --git a/user_guide_src/source/changelogs/v4.7.5.rst b/user_guide_src/source/changelogs/v4.7.5.rst index 47d447dfbbc3..7cd8169a5d34 100644 --- a/user_guide_src/source/changelogs/v4.7.5.rst +++ b/user_guide_src/source/changelogs/v4.7.5.rst @@ -34,6 +34,8 @@ Deprecations Bugs Fixed ********** +- **CLI:** Fixed a bug where pressing backspace in a ``CLI::prompt()`` erased the prompt text when the ``readline`` extension is enabled. The prompt is now passed to ``readline()`` so line redraws repaint it. + ANSI color codes in the prompt (e.g., option defaults) are wrapped in readline's non-printing markers under GNU readline so cursor positioning stays accurate. - **CLIRequest:** Fixed a bug where ``parseCommand()`` could throw a TypeError when ``argv`` is missing. - **Content Security Policy:** Fixed a bug where empty ``Content-Security-Policy``, ``Content-Security-Policy-Report-Only``, and ``Reporting-Endpoints`` response headers were generated when no corresponding values existed. - **Helpers:** Fixed a bug where ``get_dir_file_info()`` returned incomplete entries for subdirectories and missing files instead of omitting them.