Skip to content

Commit 66d7d2c

Browse files
committed
UsageException is the error a handler throws and ParseException extends it; HelpRenderer::renderError() draws either with the help, and messages write code in backticks
An application catches one class and prints the message and the help of the command in which the line went wrong by one call; two of the known consumers printed no error of the command line at all, since the right way took five lines. - UsageException carries the required $command; ParseException keeps reason, parameter and token. - renderError() writes "Error:" in the role `error`, the message with its code spans, an empty line and the help of $e->command; the stream and the exit code are the application's. - formatText() draws the code spans of any text, the rest in a given color, and without colors returns the text as it is; splitCodeSpans() is no longer internal. - The messages of the parser, of the enum check and of Normalizers write names, commands and the values given as code spans by the internal formatCode(), which escapes control characters and writes a value holding a backtick in a longer run of them.
1 parent 584a818 commit 66d7d2c

16 files changed

Lines changed: 255 additions & 127 deletions

‎docs/internals.md‎

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ layer, the facts that are expensive to rediscover.
1010
- **`Parser`** reads a command line against the tree and returns an immutable `ParseResult`.
1111
It holds no state.
1212
- **`HelpRenderer`** draws the help of any node, `render()` into a console and
13-
`renderToString()` into a string. Like `Parser`, it takes the node as an argument and keeps
13+
`renderToString()` into a string, and `renderError()` a usage error followed by that help. Like `Parser`, it takes the node as an argument and keeps
1414
nothing between calls.
1515
- **`Console`** writes to a stream and knows no `Command`. The renderer colors and writes
1616
through it; `renderToString()` without a console is plain text.
@@ -115,7 +115,7 @@ Consequences worth knowing:
115115
without losing the reason for the phases.
116116
- An exception from `normalize()`, a value outside the enum or one a normalizer refused, is
117117
wrapped into a `ParseException` naming the parameter, with the original as `previous`, so all
118-
bad values read alike ("Option --x: expects …"). A `ParseException` passes through untouched and an
118+
bad values read alike ("Option `--x`: expects …"). A `ParseException` passes through untouched and an
119119
`\Error` is not caught at all: a broken normalizer is a bug, not a bad value. The
120120
`catch (\Exception)` is deliberate, and the `dresscode:ignore` comment beside it must
121121
stand alone at the end of the line, since text after it is read as rule names.
@@ -129,6 +129,13 @@ Consequences worth knowing:
129129
`token` the raw part of the line as given (the name without its value, a letter of a bundle
130130
on its own), null for what is missing; one a normalizer throws itself passes through with the
131131
default reason `InvalidValue` and no token.
132+
- **`ParseException` extends `UsageException`**, which a handler throws for what the definition
133+
cannot express (inputs that conflict, a value that does not fit the context), so an application
134+
catches one class and draws both alike by `renderError()`. The command of either is required.
135+
- **Messages write code in backticks**, as descriptions do: names, commands and the values given,
136+
each through `HelpRenderer::formatCode()`, which also escapes control characters, since a code
137+
span does not cross a line, and writes a value holding a backtick in a longer run of them;
138+
`formatText()` and `splitCodeSpans()` read it back.
132139

133140
**`standalone`** is answered between phase 1 and phase 2, so the command is already selected:
134141
if such a flag of the selected path occurred, the other flags are taken as they occurred
@@ -272,8 +279,8 @@ break a line in different places.
272279
The help colors only through its own **roles**, which the private `HelpRenderer::Theme` maps to
273280
the colors of `Console::color()`; `Console` knows no roles. Roles are chosen by conditions, so a
274281
role missing from the map shows only when that branch is drawn in color, which the colored
275-
snapshot `tests/snapshots/fluent.ansi.txt` does for all of them but `code`, drawn in color by
276-
`tests/HelpRenderer.phpt`.
282+
snapshot `tests/snapshots/fluent.ansi.txt` does for all of them but `code` and `error`, drawn in
283+
color by `tests/HelpRenderer.phpt`.
277284

278285
## Console: one stream, two questions about it
279286

‎readme.md‎

Lines changed: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -225,7 +225,7 @@ $command->addOption('--count', normalizer: Normalizers::int(min: 1));
225225
// --count 0 → throws "Option --count: expects at least 1, 0 given."
226226
```
227227

228-
It runs only on values that really come from the command line: not on the default value, and not on the `true` of an option used without its optional value. A normalizer reports a bad value by throwing an exception. The parser turns it into a `ParseException` naming the parameter, so the user reads `Option --count: expects an integer, 'abc' given.` instead of a stack trace. An `\Error` is not caught, because a broken normalizer is a bug, not a bad value.
228+
It runs only on values that really come from the command line: not on the default value, and not on the `true` of an option used without its optional value. A normalizer reports a bad value by throwing an exception. The parser turns it into a `ParseException` naming the parameter, so the user reads ``Option `--count`: expects an integer, `abc` given.`` instead of a stack trace. An `\Error` is not caught, because a broken normalizer is a bug, not a bad value.
229229

230230
`realPath()` resolves the value to an absolute path and refuses a path that does not exist, and an empty one:
231231

@@ -473,37 +473,48 @@ When a standalone flag is used, the parser returns it as `true`, the other flags
473473
Error Handling
474474
==============
475475

476-
The parser throws `Nette\CommandLine\ParseException` for invalid user input. Its `command` property is the command in which the input went wrong, so you can show the right help:
476+
The parser throws `Nette\CommandLine\ParseException` for invalid user input. It is a `UsageException`, which your handler throws for a mistake the definition cannot express, such as two options that cannot be combined. Its `command` is the command in which the input went wrong, and `renderError()` prints the message followed by the help of that command:
477477

478478
```php
479-
use Nette\CommandLine\ParseException;
479+
use Nette\CommandLine\UsageException;
480480

481481
try {
482482
$args = (new Parser)->parse($cli);
483-
} catch (ParseException $e) {
484-
$stderr = new Console(STDERR);
485-
$stderr->writeLine("Error: {$e->getMessage()}");
486-
$stderr->writeLine();
487-
(new HelpRenderer($stderr))->render($e->command);
483+
exit($args->run());
484+
} catch (UsageException $e) {
485+
(new HelpRenderer(new Console(STDERR)))->renderError($e);
488486
exit(2);
489487
}
490488
```
491489

492-
Its `reason` tells what was wrong, as a case of the `ParseFailure` enum, such as `ParseFailure::UnknownOption` or `ParseFailure::MissingArgument`, `parameter` is the parameter the error concerns, when there is one, and `token` the part of the command line it is about, as given: the name of an option as written, without its value, a command, the first unexpected argument or a bad value. An application can word the messages itself from these three; only a suggestion such as "Did you mean --verbose?" and the commands an option of another command belongs to are left to the message. The token is raw, so escape control characters before printing it.
490+
A handler throws it with its command, which a parameter typed `Command` gets:
491+
492+
```php
493+
function (Command $command, bool $stdin, array $paths): int {
494+
if ($stdin && $paths) {
495+
throw new UsageException('Paths cannot be combined with `--stdin`.', $command);
496+
}
497+
...
498+
}
499+
```
500+
501+
The message writes code in backticks, as a description does, and `renderError()` draws it in color without them. To print the message your own way, `formatText()` of the renderer does the same for any text.
502+
503+
The `reason` of a `ParseException` tells what was wrong, as a case of the `ParseFailure` enum, such as `ParseFailure::UnknownOption` or `ParseFailure::MissingArgument`, `parameter` is the parameter the error concerns, when there is one, and `token` the part of the command line it is about, as given: the name of an option as written, without its value, a command, the first unexpected argument or a bad value. An application can word the messages itself from these three; only a suggestion such as "Did you mean --verbose?" and the commands an option of another command belongs to are left to the message. The token is raw, so escape control characters before printing it.
493504

494505
Common error messages:
495506

496507
| Error | Cause |
497508
|-------|-------|
498-
| `Option --output requires a value.` | Option used without its required value |
499-
| `Option --verbose does not accept a value.` | A value given to a flag |
500-
| `Unknown option --verbos. Did you mean --verbose?` | Unrecognized option |
501-
| `Missing required argument <file>.` | Required argument not provided |
502-
| `Unexpected arguments 'b', 'c'.` | Extra positional arguments |
503-
| `Option --format: expects json or xml, 'yaml' given.` | Value not in the enum |
504-
| `Unknown command 'chekc'. Did you mean 'check'?` | Unrecognized command |
509+
| ``Option `--output` requires a value.`` | Option used without its required value |
510+
| ``Option `--verbose` does not accept a value.`` | A value given to a flag |
511+
| ``Unknown option `--verbos`. Did you mean `--verbose`?`` | Unrecognized option |
512+
| ``Missing required argument `<file>`.`` | Required argument not provided |
513+
| ``Unexpected arguments `b`, `c`.`` | Extra positional arguments |
514+
| ``Option `--format`: expects `json` or `xml`, `yaml` given.`` | Value not in the enum |
515+
| ``Unknown command `chekc`. Did you mean `check`?`` | Unrecognized command |
505516
| `Missing command.` | A command is required, but none was given |
506-
| `Option --generate-baseline belongs to command 'check'.` | Option of another command |
517+
| ``Option `--generate-baseline` belongs to command `check`.`` | Option of another command |
507518

508519
Errors in the definition itself, such as an option name without a dash, a name used twice or a required argument after an optional one, are programming errors and throw `\InvalidArgumentException`.
509520

‎src/CommandLine/HelpRenderer.php‎

Lines changed: 55 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ final class HelpRenderer
2727
'command' => '#87D787',
2828
'value' => '#D78787',
2929
'default' => '#767676', // also the dots of a repeatable parameter
30-
'code' => '#87D7FF', // written in backticks in a description or a text
30+
'code' => '#87D7FF', // written in backticks in a description, a text or a message
31+
'error' => '#FF5F5F',
3132
];
3233

3334
private const MaxSyntaxWidth = 28;
@@ -60,6 +61,19 @@ public function render(Command $command): void
6061
}
6162

6263

64+
/**
65+
* Writes the message of the error to the console, followed by the help of the command in which it went wrong.
66+
*/
67+
public function renderError(UsageException $e): void
68+
{
69+
if ($this->console === null) {
70+
(new self(new Console, $this->width))->renderError($e);
71+
} else {
72+
$this->console->write($this->style('error', 'Error:') . ' ' . $this->formatText($e->getMessage()) . "\n\n" . $this->renderToString($e->command));
73+
}
74+
}
75+
76+
6377
/**
6478
* Returns the help of the command, colored when there is a console.
6579
*/
@@ -81,9 +95,7 @@ public function renderToString(Command $command): string
8195
? [$this->style('heading', $item->title . ':')]
8296
: [];
8397
if ($item instanceof Text) {
84-
$blocks[] = $this->console?->hasColors()
85-
? implode('', array_map(fn(array $piece) => $piece[1] ? $this->style('code', $piece[0]) : $piece[0], self::splitCodeSpans($item->text)))
86-
: $item->text;
98+
$blocks[] = $this->formatText($item->text);
8799
}
88100
}
89101

@@ -301,12 +313,30 @@ private function splitWords(string $text, ?string $role): array
301313
}
302314

303315

316+
/**
317+
* Returns the text with its code spans drawn in the color of code and without the backticks, the rest in the
318+
* given color of Console::color(); without colors the text as it is.
319+
*/
320+
public function formatText(string $text, ?string $color = null): string
321+
{
322+
if (!$this->console?->hasColors()) {
323+
return $text;
324+
}
325+
326+
$out = '';
327+
foreach (self::splitCodeSpans($text) as [$piece, $code]) {
328+
$out .= $code ? $this->style('code', $piece) : $this->console->color($color, $piece);
329+
}
330+
331+
return $out;
332+
}
333+
334+
304335
/**
305336
* Splits the text at the code spans of Markdown into pieces of text and of code, the code without its backticks.
306337
* A span closes with a run of as many backticks as opened it, so code holding a backtick is written in a longer
307338
* run, and one space padding both sides of the code is dropped; a span does not cross a line.
308339
* @return list<array{string, bool}> the piece and whether it is code
309-
* @internal
310340
*/
311341
public static function splitCodeSpans(string $text): array
312342
{
@@ -324,6 +354,26 @@ public static function splitCodeSpans(string $text): array
324354
}
325355

326356

357+
/**
358+
* Writes the code as a code span that splitCodeSpans() reads back: control characters as escape sequences, since a
359+
* span does not cross a line, in a run of backticks longer than any inside, padded with a space where the padding
360+
* would otherwise be dropped or the code starts or ends with a backtick; empty code as the empty string of a shell.
361+
* @internal
362+
*/
363+
public static function formatCode(string $code): string
364+
{
365+
if ($code === '') {
366+
return "`''`";
367+
}
368+
369+
$code = addcslashes($code, "\0..\37\177");
370+
preg_match_all('~`+~', $code, $m);
371+
$fence = str_repeat('`', max([0, ...array_map(strlen(...), $m[0])]) + 1);
372+
$pad = preg_match('~^`|`$|^ (?=.*\S).* $~s', $code) ? ' ' : '';
373+
return $fence . $pad . $code . $pad . $fence;
374+
}
375+
376+
327377
/**
328378
* Returns the default value in words: the description of the default when there is one, otherwise a scalar value
329379
* or the value of an enum case, and null when there is nothing to show.

‎src/CommandLine/Normalizers.php‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public static function int(?int $min = null, ?int $max = null): \Closure
2929
: '';
3030
$int = (int) $digits;
3131
if ((string) $int !== $digits) { // (int) clamps a number out of the range of int
32-
throw new \InvalidArgumentException("expects an integer, '$s' given.");
32+
throw new \InvalidArgumentException('expects an integer, ' . HelpRenderer::formatCode($s) . ' given.');
3333
}
3434

3535
self::checkRange($int, $min, $max);
@@ -49,7 +49,7 @@ public static function float(?float $min = null, ?float $max = null): \Closure
4949
$s = (string) $value;
5050
$float = (float) $s;
5151
if (!preg_match('#^[+-]?(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$#D', $s) || !is_finite($float)) { // 1e9999 overflows to infinity
52-
throw new \InvalidArgumentException("expects a number, '$s' given.");
52+
throw new \InvalidArgumentException('expects a number, ' . HelpRenderer::formatCode($s) . ' given.');
5353
}
5454

5555
self::checkRange($float, $min, $max);
@@ -74,9 +74,9 @@ private static function checkRange(int|float $number, int|float|null $min, int|f
7474
{
7575
if (($min !== null && $number < $min) || ($max !== null && $number > $max)) {
7676
throw new \InvalidArgumentException(match (true) {
77-
$max === null => "expects at least $min, $number given.",
78-
$min === null => "expects at most $max, $number given.",
79-
default => "expects a number from $min to $max, $number given.",
77+
$max === null => "expects at least $min, `$number` given.",
78+
$min === null => "expects at most $max, `$number` given.",
79+
default => "expects a number from $min to $max, `$number` given.",
8080
});
8181
}
8282
}
@@ -91,7 +91,7 @@ public static function realPath(): \Closure
9191
return function (mixed $value): string {
9292
$path = (string) $value === '' ? false : realpath((string) $value); // realpath('') is the working directory
9393
return $path === false
94-
? throw new \InvalidArgumentException("file path '$value' not found.")
94+
? throw new \InvalidArgumentException('file path ' . HelpRenderer::formatCode((string) $value) . ' not found.')
9595
: $path;
9696
};
9797
}

‎src/CommandLine/Parameters/ValueParameter.php‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
namespace Nette\CommandLine\Parameters;
99

10-
use Nette\CommandLine\Command;
10+
use Nette\CommandLine\{Command, HelpRenderer};
1111
use function count, is_array, is_string;
1212

1313

@@ -86,9 +86,9 @@ private static function checkValues(array $values, string $name): array
8686
public function normalize(string $value): mixed
8787
{
8888
if ($this->enum !== null && !in_array($value, $this->enum, true)) {
89-
$enum = $this->enum;
89+
$enum = array_map(HelpRenderer::formatCode(...), $this->enum);
9090
$last = array_pop($enum);
91-
throw new \InvalidArgumentException('expects ' . ($enum ? implode(', ', $enum) . " or $last" : $last) . ", '$value' given.");
91+
throw new \InvalidArgumentException('expects ' . ($enum ? implode(', ', $enum) . " or $last" : $last) . ', ' . HelpRenderer::formatCode($value) . ' given.');
9292
}
9393

9494
$result = $value;

0 commit comments

Comments
 (0)