diff --git a/system/CLI/InputOutput.php b/system/CLI/InputOutput.php index 1d99563dd0ea..782e32d0371d 100644 --- a/system/CLI/InputOutput.php +++ b/system/CLI/InputOutput.php @@ -44,12 +44,16 @@ public function input(?string $prefix = null): string // readline() can't be tested. if ($this->readlineSupport && ENVIRONMENT !== 'testing') { // @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); + $prompt = $this->readlinePrompt($prefix, readline_info('library_version')); + + if ($prompt !== null) { + return readline($prompt); } - return readline($prefix); + // The library cannot render the prompt, so write it ourselves and let readline() only read the line. + self::fwrite(STDOUT, $prefix ?? ''); + + return readline(); // @codeCoverageIgnoreEnd } @@ -86,6 +90,31 @@ public function fwrite($handle, string $string): void fwrite($handle, $string); } + /** + * Builds the prompt handed to readline(), or returns null when the caller has to write + * the prompt to STDOUT itself because the line-editing library cannot render it. + * + * GNU readline gets ANSI sequences wrapped in its non-printing markers so line-redraw + * column accounting skips them. Libedit reports "EditLine wrapper" and mangles the + * markers, so it gets the raw prompt. Windows builds use WinEditLine, which prints ANSI + * sequences literally, and readline_info() omits the version there (php-src guards it + * with #ifndef PHP_WIN32), so they get no prompt. + * + * @param mixed $libraryVersion The value of readline_info('library_version') + */ + private function readlinePrompt(?string $prefix, mixed $libraryVersion): ?string + { + if ($prefix === null || ! is_string($libraryVersion)) { + return null; + } + + if (str_contains($libraryVersion, 'EditLine')) { + return $prefix; + } + + return $this->markAnsiNonPrinting($prefix); + } + /** * Wraps ANSI escape sequences in readline's non-printing markers so line-redraw column accounting skips them. */ diff --git a/tests/system/CLI/CLITest.php b/tests/system/CLI/CLITest.php index f4db06f17102..62903f80ac64 100644 --- a/tests/system/CLI/CLITest.php +++ b/tests/system/CLI/CLITest.php @@ -267,6 +267,43 @@ public function testMarkAnsiNonPrintingWrapsEscapeSequences(): void $this->assertSame('Name: ', $wrap('Name: ')); } + #[DataProvider('provideReadlinePromptDependsOnLibrary')] + public function testReadlinePromptDependsOnLibrary(mixed $libraryVersion, ?string $expected): void + { + $build = $this->getPrivateMethodInvoker(new InputOutput(), 'readlinePrompt'); + + $this->assertSame( + $expected, + $build(sprintf('What is your favorite color? [%s]: ', CLI::color('red', 'green')), $libraryVersion), + ); + } + + /** + * @return iterable + */ + public static function provideReadlinePromptDependsOnLibrary(): iterable + { + yield 'GNU readline gets the prompt with non-printing markers' => [ + '8.2', + "What is your favorite color? [\x01\e[0;32m\x02red\x01\e[0m\x02]: ", + ]; + + yield 'libedit gets the raw prompt' => [ + 'EditLine wrapper', + "What is your favorite color? [\e[0;32mred\e[0m]: ", + ]; + + // Official Windows builds use WinEditLine, which exposes no library version. + yield 'WinEditLine gets no prompt so the caller writes it' => [null, null]; + } + + public function testReadlinePromptWithoutPrefixReturnsNull(): void + { + $build = $this->getPrivateMethodInvoker(new InputOutput(), 'readlinePrompt'); + + $this->assertNull($build(null, '8.2')); + } + 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 f13a3e4b2b49..6dee8ee9aa6d 100644 --- a/user_guide_src/source/changelogs/v4.7.5.rst +++ b/user_guide_src/source/changelogs/v4.7.5.rst @@ -38,6 +38,7 @@ 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. + On Windows, where the ``readline`` extension is built on WinEditLine, the prompt is written to STDOUT first because WinEditLine reports no library version and prints ANSI sequences literally. - **CLIRequest:** Fixed a bug where ``parseCommand()`` could throw a TypeError when ``argv`` is missing. - **CodeIgniter:** Fixed a bug where ``gatherOutput()`` could be called twice when ``startController()`` returned a ``ResponseInterface`` (e.g., from filter attributes or closure routes). - **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.