Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions system/CLI/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand Down
20 changes: 18 additions & 2 deletions system/CLI/InputOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'));

Expand Down Expand Up @@ -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);
}
}
124 changes: 124 additions & 0 deletions tests/system/CLI/CLITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 2 additions & 0 deletions user_guide_src/source/changelogs/v4.7.5.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading