Skip to content

Commit a7f6217

Browse files
committed
Prompt asks the user at a terminal: choose() reads one of the choices, the default on an empty answer and null at the end of the input
A question has two ends, the console and the input, so it is a class of its own and not a method of Console. It refuses with a LogicException before writing or reading when either end is not a terminal; inputTerminal: overrides the input for a test. The choices are a list shown as [y/N/a] after the question, or a map of a choice to its description drawn as a menu above it; the answer is taken in any case and returned as declared, an unknown one asks again. The end of the input is null, never the default, so a closed input cannot loop. The terminal echoes the answer with its newline, which Console::markLineStart(), internal, tells the console, so the next status opens no empty line.
1 parent 66d7d2c commit a7f6217

6 files changed

Lines changed: 293 additions & 2 deletions

File tree

‎AGENTS.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ three phases of `parse()` with the command selection and the sentinel, `isset()`
1919
**Nette Command Line** is a tiny, zero-dependency library. `Command` defines a
2020
command line as a tree of the program and its commands, `Parser` reads a command
2121
line against it and returns a `ParseResult`, `HelpRenderer` draws the help of any
22-
command, `Console` writes to a stream in color and `Ansi` measures text the way a
23-
terminal shows it. The parameters live in the
22+
command and a usage error, `Console` writes to a stream in color, `Prompt` asks the
23+
user at a terminal and `Ansi` measures text the way a terminal shows it. The parameters live in the
2424
`Parameters` namespace: `Flag` extends `Parameter`, while `Option` and `Argument`
2525
extend it through `ValueParameter`. `Section` and `Text` in the `Help` namespace
2626
shape the help. The attributes `Command`, `Flag`, `Option` and `Argument` in the

‎docs/internals.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,22 @@ step.
352352
`stty` about `/dev/tty`, the terminal of the process, since `exec()` hands the child the stdin,
353353
which may be a pipe; without `exec()` or a controlling terminal the width is 80.
354354

355+
## Prompt: a question has two ends
356+
357+
`Prompt` is a class of its own, not a method of `Console`, because a question has two streams, the
358+
console it writes to and the input it reads from, while a console is one.
359+
360+
- **It asks only when both ends are a terminal** and refuses with a `LogicException` before writing
361+
or reading, so an answer is never taken from a pipe nobody watches; `inputTerminal:` overrides
362+
only the input, a test makes the console a terminal by its own constructor.
363+
- **The end of the input is `null`, never the default**, and ends the question, so a closed input
364+
cannot loop; an empty line is the default, without one it asks again, as does an unknown answer.
365+
- The terminal echoes the answer with its newline, which the console does not see, so the prompt
366+
tells it by the internal `Console::markLineStart()`; otherwise the next status would open an
367+
empty line.
368+
- It owns no signal, no `exit()`, no `/dev/tty` and no mode of the terminal; those stay with the
369+
application.
370+
355371
## Ansi: the single measure of width
356372

357373
`Ansi` is the one place that knows how wide text is on a terminal: an escape sequence takes no

‎readme.md‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -589,6 +589,33 @@ Ansi::strip($text); // the text without any escape
589589
`strip()` removes escape sequences, not other control characters, so it is no sanitizer of untrusted text.
590590

591591

592+
Asking the User
593+
---------------
594+
595+
`Prompt` asks a question on the console and reads the answer from `STDIN`:
596+
597+
```php
598+
use Nette\CommandLine\Prompt;
599+
600+
$prompt = new Prompt($console);
601+
if ($prompt->isInteractive()) {
602+
$answer = $prompt->choose('Apply this fix?', ['y', 'n', 'a', 'q'], default: 'n'); // Apply this fix? [y/N/a/q]
603+
}
604+
```
605+
606+
It asks again until the answer is one of the choices, in any case, and returns the choice as you declared it. An empty answer is the default; without a default it asks again. The end of the input, Ctrl+D, returns `null`, never the default, so decide what it means yourself, such as `?? 'q'`. Choices with descriptions are given as a map and drawn as a menu above the question:
607+
608+
```php
609+
$answer = $prompt->choose('Choice', [
610+
's' => 'Skip this operation',
611+
'r' => 'Resume',
612+
'c' => 'Cancel deployment',
613+
], default: 'r');
614+
```
615+
616+
The prompt asks only when the input and the console are both a terminal, so that no answer is taken from a pipe nobody watches; otherwise `choose()` throws `LogicException` before it writes or reads anything. Ask `isInteractive()` first and decide what the script does without a user. Give the console that draws your status lines, so that the question erases them; the prompt itself handles no Ctrl+C and calls no `exit()`.
617+
618+
592619
Upgrading from 1.x
593620
==================
594621

‎src/CommandLine/Console.php‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,17 @@ public function writeLine(string $text = '', ?string $color = null): void
8484
}
8585

8686

87+
/**
88+
* Tells the console that the cursor stands at the beginning of a line, which the terminal moved it to by echoing
89+
* an answer typed by the user.
90+
* @internal
91+
*/
92+
public function markLineStart(): void
93+
{
94+
$this->atLineStart = true;
95+
}
96+
97+
8798
/**
8899
* Colors the text, or returns it as it is when the color is null or colors are off. The color is 'foreground'
89100
* or 'foreground/background' (e.g. 'red', 'white/blue'), each of them one of black, gray, silver, white, navy,

‎src/CommandLine/Prompt.php‎

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
<?php declare(strict_types=1);
2+
3+
/**
4+
* This file is part of the Nette Framework (https://nette.org)
5+
* Copyright (c) 2004 David Grudl (https://davidgrudl.com)
6+
*/
7+
8+
namespace Nette\CommandLine;
9+
10+
use function is_int, is_string;
11+
use const PHP_SAPI;
12+
13+
14+
/**
15+
* Asks the user at a terminal: writes the question to the console and reads the answer from the input. It asks only
16+
* when both are a terminal, so an answer never comes from a pipe nobody watches.
17+
*/
18+
final class Prompt
19+
{
20+
/** @var resource */
21+
private $input;
22+
private readonly bool $inputTerminal;
23+
24+
25+
/**
26+
* @param ?resource $input where the answers come from, STDIN by default
27+
* @param ?bool $inputTerminal null asks the input; a test over a memory stream passes true
28+
*/
29+
public function __construct(
30+
private readonly Console $console = new Console,
31+
$input = null,
32+
?bool $inputTerminal = null,
33+
) {
34+
$this->input = $input ?? STDIN;
35+
$this->inputTerminal = $inputTerminal
36+
?? ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') && @stream_isatty($this->input)); // @ may trigger error 'cannot cast a filtered stream on this system'
37+
}
38+
39+
40+
/**
41+
* Tells whether a user answers at a terminal, so that the application can ask; without one, the prompt refuses.
42+
*/
43+
public function isInteractive(): bool
44+
{
45+
return $this->inputTerminal && $this->console->isTerminal();
46+
}
47+
48+
49+
/**
50+
* Asks the question until the answer is one of the choices, in any case, and returns the choice as declared. An
51+
* empty answer is the default, without one it asks again; the end of the input is null. The choices are a list,
52+
* such as ['y', 'n'], shown as [y/N] after the question, or a map of a choice to its description, each written as
53+
* a line of a menu above the question.
54+
* @param list<string>|array<string, string> $choices
55+
* @throws \LogicException when the prompt is not interactive
56+
*/
57+
public function choose(string $question, array $choices, ?string $default = null): ?string
58+
{
59+
$menu = array_is_list($choices) ? [] : $choices;
60+
$keys = self::checkChoices($menu ? array_keys($menu) : $choices, $default);
61+
if (!$this->isInteractive()) {
62+
throw new \LogicException('The prompt has no user to ask, so it asks only when isInteractive() is true.');
63+
}
64+
65+
$prompt = $question . ' [' . implode('/', array_map(fn($choice) => $choice === $default ? strtoupper($choice) : $choice, $keys)) . '] ';
66+
$text = '';
67+
foreach ($menu as $choice => $description) {
68+
$text .= " [$choice] $description\n";
69+
}
70+
71+
$text .= $prompt;
72+
while (true) {
73+
$this->console->write($text);
74+
$text = $prompt; // the menu is not repeated
75+
$line = fgets($this->input);
76+
if ($line === false) {
77+
$this->console->writeLine();
78+
return null;
79+
}
80+
81+
$this->console->markLineStart();
82+
$answer = strtolower(trim($line));
83+
if ($answer === '' && $default !== null) {
84+
return $default;
85+
}
86+
87+
foreach ($keys as $choice) {
88+
if (strtolower($choice) === $answer) {
89+
return $choice;
90+
}
91+
}
92+
}
93+
}
94+
95+
96+
/**
97+
* @param list<mixed> $choices
98+
* @return list<string>
99+
*/
100+
private static function checkChoices(array $choices, ?string $default): array
101+
{
102+
if (!$choices) {
103+
throw new \InvalidArgumentException('There are no choices.');
104+
}
105+
106+
$list = $seen = [];
107+
foreach ($choices as $choice) {
108+
if (is_int($choice)) {
109+
throw new \InvalidArgumentException("Choice $choice is a number, but a choice is a string.");
110+
} elseif (!is_string($choice) || $choice === '' || trim($choice) !== $choice) {
111+
throw new \InvalidArgumentException('A choice must be a string with no surrounding whitespace.');
112+
} elseif (isset($seen[strtolower($choice)])) {
113+
throw new \InvalidArgumentException("Choice '$choice' is given twice, the case aside.");
114+
}
115+
116+
$seen[strtolower($choice)] = true;
117+
$list[] = $choice;
118+
}
119+
120+
if ($default !== null && !in_array($default, $list, true)) {
121+
throw new \InvalidArgumentException("Default '$default' is not one of the choices.");
122+
}
123+
124+
return $list;
125+
}
126+
}

‎tests/Prompt.phpt‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
<?php declare(strict_types=1);
2+
3+
use Nette\CommandLine\{ColorDepth, Console, Prompt};
4+
use Tester\Assert;
5+
6+
require __DIR__ . '/bootstrap.php';
7+
8+
9+
/** @return array{Prompt, resource} */
10+
function prompt(string $answers, bool $terminal = true): array
11+
{
12+
$input = fopen('php://memory', 'w+');
13+
fwrite($input, $answers);
14+
rewind($input);
15+
$output = fopen('php://memory', 'w+');
16+
return [new Prompt(new Console($output, ColorDepth::None, terminal: $terminal), $input, inputTerminal: $terminal), $output];
17+
}
18+
19+
20+
/** @param resource $output */
21+
function written($output): string
22+
{
23+
rewind($output);
24+
return (string) stream_get_contents($output);
25+
}
26+
27+
28+
test('the answer is one of the choices, in any case, returned as declared', function () {
29+
[$prompt, $output] = prompt("Y\n");
30+
Assert::same('y', $prompt->choose('Apply this fix?', ['y', 'n', 'a', 'q']));
31+
Assert::same('Apply this fix? [y/n/a/q] ', written($output));
32+
33+
[$prompt] = prompt("all\n");
34+
Assert::same('All', $prompt->choose('Apply?', ['Yes', 'All']));
35+
});
36+
37+
38+
test('an unknown answer asks again, an empty one is the default', function () {
39+
[$prompt, $output] = prompt("x\n\n");
40+
Assert::same('r', $prompt->choose('Choice', ['s', 'r', 'c'], default: 'r'));
41+
Assert::same('Choice [s/R/c] Choice [s/R/c] ', written($output));
42+
43+
[$prompt, $output] = prompt("\nn\n"); // without a default an empty answer asks again
44+
Assert::same('n', $prompt->choose('Go on?', ['y', 'n']));
45+
Assert::same('Go on? [y/n] Go on? [y/n] ', written($output));
46+
});
47+
48+
49+
test('the end of the input is null, never the default', function () {
50+
[$prompt, $output] = prompt('');
51+
Assert::null($prompt->choose('Go on?', ['y', 'n'], default: 'y'));
52+
Assert::same("Go on? [Y/n] \n", written($output));
53+
54+
[$prompt] = prompt("x\n"); // an unknown answer and then the end
55+
Assert::null($prompt->choose('Go on?', ['y', 'n']));
56+
});
57+
58+
59+
test('a map of choices is drawn as a menu above the question, which is not repeated', function () {
60+
[$prompt, $output] = prompt("x\ns\n");
61+
$choice = $prompt->choose('Choice', [
62+
's' => 'Skip this operation',
63+
'r' => 'Resume',
64+
'c' => 'Cancel deployment',
65+
], default: 'r');
66+
Assert::same('s', $choice);
67+
Assert::same(
68+
" [s] Skip this operation\n"
69+
. " [r] Resume\n"
70+
. " [c] Cancel deployment\n"
71+
. 'Choice [s/R/c] Choice [s/R/c] ',
72+
written($output),
73+
);
74+
});
75+
76+
77+
test('without a user at a terminal the prompt refuses before it reads or writes', function () {
78+
[$prompt, $output] = prompt("y\n", terminal: false);
79+
Assert::false($prompt->isInteractive());
80+
Assert::exception(fn() => $prompt->choose('Go on?', ['y', 'n']), LogicException::class);
81+
Assert::same('', written($output));
82+
83+
$input = fopen('php://memory', 'w+');
84+
$output = fopen('php://memory', 'w+');
85+
Assert::false((new Prompt(new Console($output, terminal: false), $input, inputTerminal: true))->isInteractive());
86+
Assert::false((new Prompt(new Console($output, terminal: true), $input))->isInteractive());
87+
Assert::true((new Prompt(new Console($output, terminal: true), $input, inputTerminal: true))->isInteractive());
88+
});
89+
90+
91+
test('wrong choices are an error of the call', function () {
92+
[$prompt] = prompt('');
93+
Assert::exception(fn() => $prompt->choose('?', []), InvalidArgumentException::class, 'There are no choices.');
94+
Assert::exception(fn() => $prompt->choose('?', ['1' => 'One', 'n' => 'No']), InvalidArgumentException::class, 'Choice 1 is a number, but a choice is a string.');
95+
Assert::exception(fn() => $prompt->choose('?', [1, 2]), InvalidArgumentException::class, 'Choice 1 is a number, but a choice is a string.');
96+
Assert::exception(fn() => $prompt->choose('?', ['y', '']), InvalidArgumentException::class, 'A choice must be a string with no surrounding whitespace.');
97+
Assert::exception(fn() => $prompt->choose('?', ['y', 'Y']), InvalidArgumentException::class, "Choice 'Y' is given twice, the case aside.");
98+
Assert::exception(fn() => $prompt->choose('?', ['y', 'n'], default: 'x'), InvalidArgumentException::class, "Default 'x' is not one of the choices.");
99+
});
100+
101+
102+
test('the answer leaves the cursor at the start of a line, so a status needs no new line', function () {
103+
$input = fopen('php://memory', 'w+');
104+
fwrite($input, "y\n");
105+
rewind($input);
106+
$output = fopen('php://memory', 'w+');
107+
$console = new Console($output, ColorDepth::None, terminal: true);
108+
(new Prompt($console, $input, inputTerminal: true))->choose('Go on?', ['y', 'n']);
109+
$console->setStatus('working');
110+
Assert::same("Go on? [y/n] \e[?25lworking\r", written($output));
111+
});

0 commit comments

Comments
 (0)