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
9 changes: 9 additions & 0 deletions system/CLI/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,10 @@ public static function prompt(string $field, $options = null, $validation = null

if ($validation !== []) {
while (! static::validate('"' . trim($field) . '"', $input, $validation)) {
if (static::$io->isAtEnd()) {
throw CLIException::forInputEnded();
}

$input = static::prompt($field, $options, $validation);
}
}
Expand Down Expand Up @@ -339,6 +343,11 @@ public static function promptByMultipleKeys(string $text, array $options): array
}

static::error('Please select correctly.');

if (static::$io->isAtEnd()) {
throw CLIException::forInputEnded();
}

CLI::newLine();
}
}
Expand Down
11 changes: 8 additions & 3 deletions system/CLI/Exceptions/CLIException.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,6 @@
use CodeIgniter\Exceptions\DebugTraceableTrait;
use CodeIgniter\Exceptions\RuntimeException;

/**
* CLIException
*/
class CLIException extends RuntimeException
{
use DebugTraceableTrait;
Expand All @@ -33,4 +30,12 @@ public static function forInvalidColor(string $type, string $color)
{
return new static(lang('CLI.invalidColor', [$type, $color]));
}

/**
* Thrown when a prompt must ask again but the input has ended.
*/
public static function forInputEnded(): static
{
return new static(lang('CLI.inputEnded'));
}
}
19 changes: 17 additions & 2 deletions system/CLI/InputOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ class InputOutput
*/
private readonly bool $readlineSupport;

private bool $atEnd = false;

public function __construct()
{
// Readline is an extension for PHP that makes interactivity with PHP
Expand Down Expand Up @@ -51,7 +53,8 @@ public function input(?string $prefix = null): string
self::fwrite(STDOUT, $prefix ?? '');
}

$input = readline($prompt);
$input = readline($prompt);
$this->atEnd = $input === false && (! defined('STDIN') || ! stream_isatty(STDIN));

return $input === false ? '' : $input;
// @codeCoverageIgnoreEnd
Expand All @@ -60,7 +63,9 @@ public function input(?string $prefix = null): string
// self:: skips MockInputOutput's fwrite override, whose filter bookkeeping must not nest inside input().
self::fwrite(STDOUT, $prefix ?? '');

$input = fgets(fopen('php://stdin', 'rb'));
$stdin = fopen('php://stdin', 'rb');
$input = fgets($stdin);
$this->atEnd = $input === false && ! stream_isatty($stdin);

if ($input === false) {
return '';
Expand All @@ -69,6 +74,16 @@ public function input(?string $prefix = null): string
return $input;
}

/**
* Whether the last `input()` found the end of an input other than a terminal,
* so that no later read can bring an answer. At a terminal, `Ctrl+D` ends only
* the current read, and `input()` returns an empty string as for an empty line.
*/
public function isAtEnd(): bool
{
return $this->atEnd;
}

/**
* While the library is intended for use on CLI commands,
* commands can be called from controllers and elsewhere
Expand Down
1 change: 1 addition & 0 deletions system/Language/en/CLI.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
'helpDescription' => 'Description:',
'helpOptions' => 'Options:',
'helpUsage' => 'Usage:',
'inputEnded' => 'The input ended before a valid answer was given.',
'invalidColor' => 'Invalid "{0}" color: "{1}".',
'namespaceNotDefined' => 'Namespace "{0}" is not defined.',
'signals' => [
Expand Down
8 changes: 8 additions & 0 deletions system/Test/Mock/MockInputOutput.php
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,14 @@ public function input(?string $prefix = null): string
return $input;
}

/**
* Whether the scripted answers have run out, so that a scripted empty answer before the last stays an empty line.
*/
public function isAtEnd(): bool
{
return $this->inputs === [];
}

public function fwrite($handle, string $string): void
{
$this->addStreamFilters();
Expand Down
8 changes: 8 additions & 0 deletions system/Test/PhpStreamWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ public function stream_open(): bool
return true;
}

/**
* Returns `false`, since the stream has no descriptor.
*/
public function stream_cast(): false
{
return false;
}

/**
* @return string
*/
Expand Down
56 changes: 56 additions & 0 deletions tests/system/CLI/CLITest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

namespace CodeIgniter\CLI;

use CodeIgniter\CLI\Exceptions\CLIException;
use CodeIgniter\Config\Services;
use CodeIgniter\Exceptions\RuntimeException;
use CodeIgniter\Superglobals;
Expand Down Expand Up @@ -448,6 +449,61 @@ public function testPromptByMultipleKeysDefaultsToTheFirstKey(): void
$this->assertStringNotContainsString('Please select correctly.', $io->getOutput());
}

public function testInputReportsTheEndOfTheInput(): void
{
$io = new InputOutput();

PhpStreamWrapper::register();
PhpStreamWrapper::setContent('red');
$io->input();
$this->assertFalse($io->isAtEnd());

PhpStreamWrapper::setContent('');
$io->input();
$this->assertTrue($io->isAtEnd());

PhpStreamWrapper::restore();
}

/**
* @param list<string> $inputs
* @param callable(): mixed $prompt
*/
#[DataProvider('providePromptsStopAskingWhenTheInputEnds')]
public function testPromptsStopAskingWhenTheInputEnds(array $inputs, callable $prompt): void
{
$io = new MockInputOutput();
$io->setInputs($inputs);
CLI::setInputOutput($io);

$this->expectException(CLIException::class);
$this->expectExceptionMessage('The input ended before a valid answer was given.');

$prompt();
}

/**
* @return iterable<string, array{list<string>, callable(): mixed}>
*/
public static function providePromptsStopAskingWhenTheInputEnds(): iterable
{
yield 'prompt' => [[''], static fn (): string => CLI::prompt('Name', null, 'required')];

yield 'promptByKey' => [['c'], static fn (): string => CLI::promptByKey('Pick:', ['a' => 'Apple', 'b' => 'Banana'], 'in_list[b]')];

yield 'promptByMultipleKeys' => [['9'], static fn (): array => CLI::promptByMultipleKeys('Pick:', ['a' => 'Apple', 'b' => 'Banana'])];
}

public function testMockInputOutputKeepsAnEmptyAnswerBeforeTheLastAsAnEmptyLine(): void
{
$io = new MockInputOutput();
$io->setInputs(['', 'red']);
CLI::setInputOutput($io);

$this->assertSame('red', CLI::prompt('Color', null, 'required'));
$this->assertTrue($io->isAtEnd());
}

public function testNewLine(): void
{
$this->expectOutputString('');
Expand Down
2 changes: 2 additions & 0 deletions user_guide_src/source/changelogs/v4.7.6.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ BREAKING
Message Changes
***************

- Added the ``CLI.inputEnded`` language string.
- Added the ``Format.invalidXMLValue`` language string.

*******
Expand All @@ -35,6 +36,7 @@ Bugs Fixed
**********

- **CLI:** Fixed a bug where ``CLI::promptByMultipleKeys()`` accepted a key missing from the options when it was below the last key, returning an empty selection, rejected a key above the last one when the options were not in key order, and offered ``0`` as the default when the first key was not ``0``.
- **CLI:** Fixed a bug where a prompt asked again forever when its input was piped or redirected and ran out before a valid answer: ``CLI::prompt()`` and ``CLI::promptByKey()`` whose default fails the validation rules, and ``CLI::promptByMultipleKeys()`` whose options have string keys. They now throw a ``CLIException``. The new ``InputOutput::isAtEnd()`` tells that end of the input from an empty line. Pressing Ctrl+D at a terminal still asks again.
- **Commands:** Fixed a bug where generator commands did not sort the imports of a template with CRLF line endings.
- **Format:** Fixed ``XMLFormatter`` so that an array key that becomes empty after sanitization no longer triggers a ``ValueError``; the fallback tag name ``item`` is used instead. Values that cannot be converted to a string, such as objects not implementing ``Stringable``, now throw a ``FormatException`` instead of a fatal ``Error``.
- **Helpers:** Fixed a regression in v4.7.5 where ``set_value()`` threw a ``TypeError`` when the default value was not a string or an array, such as an integer, a float, ``null``, or a ``Stringable`` object. Such values are now converted to a string.
Expand Down
Loading