Skip to content

Commit 7fa157f

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 70a62a7 commit 7fa157f

6 files changed

Lines changed: 309 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: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
if (!preg_match('#[r+]#', stream_get_meta_data($this->input)['mode'])) {
36+
throw new \InvalidArgumentException('The stream is not open for reading.');
37+
}
38+
39+
$this->inputTerminal = $inputTerminal
40+
?? ((PHP_SAPI === 'cli' || PHP_SAPI === 'phpdbg') && @stream_isatty($this->input)); // @ may trigger error 'cannot cast a filtered stream on this system'
41+
}
42+
43+
44+
/**
45+
* Tells whether a user answers at a terminal, so that the application can ask; without one, the prompt refuses.
46+
*/
47+
public function isInteractive(): bool
48+
{
49+
return $this->inputTerminal && $this->console->isTerminal();
50+
}
51+
52+
53+
/**
54+
* Asks the question until the answer is one of the choices, in any case, and returns the choice as declared. An
55+
* empty answer is the default, without one it asks again; the end of the input is null. The choices are a list,
56+
* such as ['y', 'n'], shown as [y/N] after the question, or a map of a choice to its description, each written as
57+
* a line of a menu above the question.
58+
* @param list<string>|array<string, string> $choices
59+
* @throws \LogicException when the prompt is not interactive
60+
*/
61+
public function choose(string $question, array $choices, ?string $default = null): ?string
62+
{
63+
$menu = array_is_list($choices) ? [] : $choices;
64+
$keys = self::checkChoices($menu ? array_keys($menu) : $choices, $default);
65+
if (!$this->isInteractive()) {
66+
throw new \LogicException('The prompt has no user to ask, so it asks only when isInteractive() is true.');
67+
}
68+
69+
// only the default is in capitals, so that no other choice looks like it
70+
$prompt = $question . ' [' . implode('/', array_map(fn($choice) => $choice === $default ? strtoupper($choice) : strtolower($choice), $keys)) . '] ';
71+
$text = '';
72+
foreach ($menu as $choice => $description) {
73+
$text .= " [$choice] $description\n";
74+
}
75+
76+
$text .= $prompt;
77+
while (true) {
78+
$this->console->write($text);
79+
$text = $prompt; // the menu is not repeated
80+
$line = fgets($this->input);
81+
if ($line === false) {
82+
$this->console->writeLine();
83+
return null;
84+
}
85+
86+
$this->console->markLineStart();
87+
$answer = strtolower(trim($line));
88+
if ($answer === '' && $default !== null) {
89+
return $default;
90+
}
91+
92+
foreach ($keys as $choice) {
93+
if (strtolower($choice) === $answer) {
94+
return $choice;
95+
}
96+
}
97+
}
98+
}
99+
100+
101+
/**
102+
* @param list<mixed> $choices
103+
* @return list<string>
104+
*/
105+
private static function checkChoices(array $choices, ?string $default): array
106+
{
107+
if (!$choices) {
108+
throw new \InvalidArgumentException('There are no choices.');
109+
}
110+
111+
$list = $seen = [];
112+
foreach ($choices as $choice) {
113+
if (is_int($choice)) {
114+
throw new \InvalidArgumentException("Choice $choice is a number, but a choice is a string.");
115+
} elseif (!is_string($choice) || $choice === '' || trim($choice) !== $choice) {
116+
throw new \InvalidArgumentException('A choice must be a string with no surrounding whitespace.');
117+
} elseif (isset($seen[strtolower($choice)])) {
118+
throw new \InvalidArgumentException("Choice '$choice' is given twice, the case aside.");
119+
}
120+
121+
$seen[strtolower($choice)] = true;
122+
$list[] = $choice;
123+
}
124+
125+
if ($default !== null && !in_array($default, $list, true)) {
126+
throw new \InvalidArgumentException("Default '$default' is not one of the choices.");
127+
}
128+
129+
return $list;
130+
}
131+
}

‎tests/Prompt.phpt‎

Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
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, $output] = prompt("all\n");
34+
Assert::same('All', $prompt->choose('Apply?', ['Yes', 'All', 'no'], default: 'no'));
35+
Assert::same('Apply? [yes/all/NO] ', written($output)); // only the default is in capitals
36+
});
37+
38+
39+
test('an unknown answer asks again, an empty one is the default', function () {
40+
[$prompt, $output] = prompt("x\n\n");
41+
Assert::same('r', $prompt->choose('Choice', ['s', 'r', 'c'], default: 'r'));
42+
Assert::same('Choice [s/R/c] Choice [s/R/c] ', written($output));
43+
44+
[$prompt, $output] = prompt("\nn\n"); // without a default an empty answer asks again
45+
Assert::same('n', $prompt->choose('Go on?', ['y', 'n']));
46+
Assert::same('Go on? [y/n] Go on? [y/n] ', written($output));
47+
});
48+
49+
50+
test('the end of the input is null, never the default', function () {
51+
[$prompt, $output] = prompt('');
52+
Assert::null($prompt->choose('Go on?', ['y', 'n'], default: 'y'));
53+
Assert::same("Go on? [Y/n] \n", written($output));
54+
55+
[$prompt] = prompt("x\n"); // an unknown answer and then the end
56+
Assert::null($prompt->choose('Go on?', ['y', 'n']));
57+
});
58+
59+
60+
test('a map of choices is drawn as a menu above the question, which is not repeated', function () {
61+
[$prompt, $output] = prompt("x\ns\n");
62+
$choice = $prompt->choose('Choice', [
63+
's' => 'Skip this operation',
64+
'r' => 'Resume',
65+
'c' => 'Cancel deployment',
66+
], default: 'r');
67+
Assert::same('s', $choice);
68+
Assert::same(
69+
" [s] Skip this operation\n"
70+
. " [r] Resume\n"
71+
. " [c] Cancel deployment\n"
72+
. 'Choice [s/R/c] Choice [s/R/c] ',
73+
written($output),
74+
);
75+
});
76+
77+
78+
test('without a user at a terminal the prompt refuses before it reads or writes', function () {
79+
[$prompt, $output] = prompt("y\n", terminal: false);
80+
Assert::false($prompt->isInteractive());
81+
Assert::exception(fn() => $prompt->choose('Go on?', ['y', 'n']), LogicException::class);
82+
Assert::same('', written($output));
83+
84+
$input = fopen('php://memory', 'w+');
85+
$output = fopen('php://memory', 'w+');
86+
Assert::false((new Prompt(new Console($output, terminal: false), $input, inputTerminal: true))->isInteractive());
87+
Assert::false((new Prompt(new Console($output, terminal: true), $input))->isInteractive());
88+
Assert::true((new Prompt(new Console($output, terminal: true), $input, inputTerminal: true))->isInteractive());
89+
});
90+
91+
92+
test('an input not open for reading is refused', function () {
93+
$output = fopen('php://memory', 'w+');
94+
Assert::exception(
95+
fn() => new Prompt(new Console($output), fopen('php://output', 'w')),
96+
InvalidArgumentException::class,
97+
'The stream is not open for reading.',
98+
);
99+
});
100+
101+
102+
test('wrong choices are an error of the call', function () {
103+
[$prompt] = prompt('');
104+
Assert::exception(fn() => $prompt->choose('?', []), InvalidArgumentException::class, 'There are no choices.');
105+
Assert::exception(fn() => $prompt->choose('?', ['1' => 'One', 'n' => 'No']), InvalidArgumentException::class, 'Choice 1 is a number, but a choice is a string.');
106+
Assert::exception(fn() => $prompt->choose('?', [1, 2]), InvalidArgumentException::class, 'Choice 1 is a number, but a choice is a string.');
107+
Assert::exception(fn() => $prompt->choose('?', ['y', '']), InvalidArgumentException::class, 'A choice must be a string with no surrounding whitespace.');
108+
Assert::exception(fn() => $prompt->choose('?', ['y', 'Y']), InvalidArgumentException::class, "Choice 'Y' is given twice, the case aside.");
109+
Assert::exception(fn() => $prompt->choose('?', ['y', 'n'], default: 'x'), InvalidArgumentException::class, "Default 'x' is not one of the choices.");
110+
});
111+
112+
113+
test('the answer leaves the cursor at the start of a line, so a status needs no new line', function () {
114+
$input = fopen('php://memory', 'w+');
115+
fwrite($input, "y\n");
116+
rewind($input);
117+
$output = fopen('php://memory', 'w+');
118+
$console = new Console($output, ColorDepth::None, terminal: true);
119+
(new Prompt($console, $input, inputTerminal: true))->choose('Go on?', ['y', 'n']);
120+
$console->setStatus('working');
121+
Assert::same("Go on? [y/n] \e[?25lworking\r", written($output));
122+
});

0 commit comments

Comments
 (0)