Skip to content

Commit dfd88c2

Browse files
committed
fix: pass prompt text to readline in CLI::prompt() so backspace does not erase it
1 parent a826e69 commit dfd88c2

4 files changed

Lines changed: 148 additions & 6 deletions

File tree

system/CLI/CLI.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -254,12 +254,12 @@ public static function prompt(string $field, $options = null, $validation = null
254254
$default = $options[0];
255255
}
256256

257-
static::fwrite(STDOUT, $field . (trim($field) !== '' ? ' ' : '') . $extraOutput . ': ');
258257
static::$lastWrite = 'write';
259258

260-
// Read the input from keyboard.
261-
$input = trim(static::$io->input());
262-
$input = ($input === '') ? (string) $default : $input;
259+
// The reader renders the prompt itself, so readline redraws repaint it instead of erasing it.
260+
$prompt = sprintf('%s%s%s: ', $field, trim($field) !== '' ? ' ' : '', $extraOutput);
261+
$input = trim(static::$io->input($prompt));
262+
$input = $input === '' ? (string) $default : $input;
263263

264264
if ($validation !== []) {
265265
while (! static::validate('"' . trim($field) . '"', $input, $validation)) {

system/CLI/InputOutput.php

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,18 @@ public function input(?string $prefix = null): string
4343
{
4444
// readline() can't be tested.
4545
if ($this->readlineSupport && ENVIRONMENT !== 'testing') {
46-
return readline($prefix); // @codeCoverageIgnore
46+
// @codeCoverageIgnoreStart
47+
// Libedit reports "EditLine wrapper" and mangles the markers, so only GNU readline gets them.
48+
if ($prefix !== null && ! str_contains(readline_info('library_version'), 'EditLine')) {
49+
$prefix = $this->markAnsiNonPrinting($prefix);
50+
}
51+
52+
return readline($prefix);
53+
// @codeCoverageIgnoreEnd
4754
}
4855

49-
echo $prefix;
56+
// self:: skips MockInputOutput's fwrite override, whose filter bookkeeping must not nest inside input().
57+
self::fwrite(STDOUT, $prefix ?? '');
5058

5159
$input = fgets(fopen('php://stdin', 'rb'));
5260

@@ -77,4 +85,12 @@ public function fwrite($handle, string $string): void
7785

7886
fwrite($handle, $string);
7987
}
88+
89+
/**
90+
* Wraps ANSI escape sequences in readline's non-printing markers so line-redraw column accounting skips them.
91+
*/
92+
private function markAnsiNonPrinting(string $prefix): string
93+
{
94+
return preg_replace('/(\e\[[0-9;]*m)/', "\x01\$1\x02", $prefix);
95+
}
8096
}

tests/system/CLI/CLITest.php

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
use CodeIgniter\Exceptions\RuntimeException;
1818
use CodeIgniter\Superglobals;
1919
use CodeIgniter\Test\CIUnitTestCase;
20+
use CodeIgniter\Test\Mock\MockInputOutput;
2021
use CodeIgniter\Test\PhpStreamWrapper;
2122
use CodeIgniter\Test\StreamFilterTrait;
2223
use PHPUnit\Framework\Attributes\DataProvider;
@@ -143,6 +144,129 @@ public function testPromptInputZero(): void
143144
$this->assertSame('0', $output);
144145
}
145146

147+
public function testPromptPassesPromptTextToInputReader(): void
148+
{
149+
$io = new class () extends InputOutput {
150+
public ?string $receivedPrefix = null;
151+
152+
public function input(?string $prefix = null): string
153+
{
154+
$this->receivedPrefix = $prefix;
155+
156+
return 'red';
157+
}
158+
};
159+
CLI::setInputOutput($io);
160+
161+
$output = CLI::prompt('What is your favorite color?');
162+
163+
CLI::resetInputOutput();
164+
165+
$this->assertSame('red', $output);
166+
$this->assertSame('What is your favorite color? : ', $io->receivedPrefix);
167+
}
168+
169+
public function testPromptPassesDefaultOptionInPromptText(): void
170+
{
171+
$io = new class () extends InputOutput {
172+
public ?string $receivedPrefix = null;
173+
174+
public function input(?string $prefix = null): string
175+
{
176+
$this->receivedPrefix = $prefix;
177+
178+
return '';
179+
}
180+
};
181+
CLI::setInputOutput($io);
182+
183+
$output = CLI::prompt('What is your favorite color?', 'red');
184+
185+
CLI::resetInputOutput();
186+
187+
$this->assertSame('red', $output);
188+
$this->assertSame(
189+
sprintf('What is your favorite color? [%s]: ', CLI::color('red', 'green')),
190+
$io->receivedPrefix,
191+
);
192+
}
193+
194+
public function testPromptByKeyPassesPromptTextToInputReader(): void
195+
{
196+
$io = new class () extends InputOutput {
197+
public ?string $receivedPrefix = null;
198+
199+
public function input(?string $prefix = null): string
200+
{
201+
$this->receivedPrefix = $prefix;
202+
203+
return '1';
204+
}
205+
};
206+
CLI::setInputOutput($io);
207+
208+
$output = CLI::promptByKey('Select your hobbies:', ['Playing game', 'Sleep', 'Badminton']);
209+
210+
CLI::resetInputOutput();
211+
212+
$this->assertSame('1', $output);
213+
$this->assertSame(
214+
PHP_EOL . sprintf('[%s, 1, 2]: ', CLI::color('0', 'green')),
215+
$io->receivedPrefix,
216+
);
217+
}
218+
219+
public function testPromptByMultipleKeysPassesPromptTextToInputReader(): void
220+
{
221+
$io = new class () extends InputOutput {
222+
public ?string $receivedPrefix = null;
223+
224+
public function input(?string $prefix = null): string
225+
{
226+
$this->receivedPrefix = $prefix;
227+
228+
return '0,1';
229+
}
230+
};
231+
CLI::setInputOutput($io);
232+
233+
$output = CLI::promptByMultipleKeys('Select your hobbies:', ['Playing game', 'Sleep', 'Badminton']);
234+
235+
CLI::resetInputOutput();
236+
237+
$this->assertSame([0 => 'Playing game', 1 => 'Sleep'], $output);
238+
$this->assertSame(
239+
'You can specify multiple values separated by commas.' . PHP_EOL
240+
. sprintf('[%s, 1, 2] : ', CLI::color('0', 'green')),
241+
$io->receivedPrefix,
242+
);
243+
}
244+
245+
public function testInputWritesPrefixToStdout(): void
246+
{
247+
$io = new MockInputOutput();
248+
$io->setInputs(['blue']);
249+
CLI::setInputOutput($io);
250+
251+
$output = CLI::input('Name: ');
252+
253+
CLI::resetInputOutput();
254+
255+
$this->assertSame('blue', $output);
256+
$this->assertSame('Name: blue' . PHP_EOL, $io->getOutput());
257+
}
258+
259+
public function testMarkAnsiNonPrintingWrapsEscapeSequences(): void
260+
{
261+
$wrap = $this->getPrivateMethodInvoker(new InputOutput(), 'markAnsiNonPrinting');
262+
263+
$this->assertSame(
264+
"What is your favorite color? [\x01\e[0;32m\x02red\x01\e[0m\x02]: ",
265+
$wrap(sprintf('What is your favorite color? [%s]: ', CLI::color('red', 'green'))),
266+
);
267+
$this->assertSame('Name: ', $wrap('Name: '));
268+
}
269+
146270
public function testPromptByKey(): void
147271
{
148272
PhpStreamWrapper::register();

user_guide_src/source/changelogs/v4.7.5.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@ Deprecations
3434
Bugs Fixed
3535
**********
3636

37+
- **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.
38+
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.
3739
- **CLIRequest:** Fixed a bug where ``parseCommand()`` could throw a TypeError when ``argv`` is missing.
3840
- **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.
3941
- **Helpers:** Fixed a bug where ``get_dir_file_info()`` returned incomplete entries for subdirectories and missing files instead of omitting them.

0 commit comments

Comments
 (0)