@gunshi/plugin-renderer 0.37.3 → 1.0.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  > usage renderer plugin for gunshi.
8
8
 
9
- This plugin provides customizable rendering for CLI help messages, usage information, and validation errors. It automatically formats command descriptions, arguments, options, examples, and error messages in a consistent and readable format.
9
+ This plugin provides customizable rendering for CLI help messages, usage information, and validation errors. It automatically formats command descriptions, arguments, options, examples, and error messages in a consistent and readable format. Description columns are aligned by the width a terminal draws, including full-width names; emoji sequences joined with ZWJ may still differ by terminal.
10
10
 
11
11
  ## 💿 Installation
12
12
 
@@ -136,7 +136,9 @@ Available extensions:
136
136
 
137
137
  - **`text<K>(key: K, values?: Record<string, unknown>): Promise<string>`**: Render text with optional i18n support. Handles built-in keys, argument descriptions, and custom keys intelligently.
138
138
 
139
- - **`loadCommands<G>(): Promise<Command<G>[]>`**: Load and cache subcommands for rendering command lists. Results are cached after the first call for performance.
139
+ - **`loadCommands<G>(): Promise<Command<G>[]>`**: Load and cache commands for rendering command lists. At the top level, the list also includes the CLI's entry command when plugins registered the subcommands without adding the entry to the registry. Results are cached after the first call for performance.
140
+
141
+ - **`localizeCommandDescription(cmd): Promise<string>`**: Resolve the description of a command in the command list. Localized when `@gunshi/plugin-i18n` is installed and the command has a resource of its own; otherwise `Command.description`.
140
142
 
141
143
  ### Usage Example
142
144
 
package/lib/index.d.ts CHANGED
@@ -1,18 +1,567 @@
1
1
  import { Args, Command, CommandContext, DefaultGunshiParams, GunshiParams, PluginWithExtension } from "@gunshi/plugin";
2
- import { Args as Args$1 } from "args-tokens";
3
-
4
- //#region ../gunshi/src/types.d.ts
5
- /**
6
- * @author kazuya kawaguchi (a.k.a. kazupon)
7
- * @license MIT
8
- */
9
-
2
+ import "@gunshi/plugin-i18n";
3
+ //#region ../../node_modules/.pnpm/args-tokens@1.0.0-beta.1/node_modules/args-tokens/lib/resolver.d.ts
4
+ //#region src/resolver.d.ts
5
+ /**
6
+ * An argument schema definition for command-line argument parsing.
7
+ *
8
+ * This schema is similar to the schema of Node.js `util.parseArgs` but with extended features:
9
+ * - Additional `required`, `description`, and `hidden` properties
10
+ * - Extended `type` support: 'string', 'boolean', 'number', 'enum', 'positional', 'custom'
11
+ * - Simplified `default` property (single type, not union types)
12
+ *
13
+ * @example
14
+ * Basic string argument:
15
+ * ```ts
16
+ * const schema: ArgSchema = {
17
+ * type: 'string',
18
+ * description: 'Server hostname',
19
+ * default: 'localhost'
20
+ * }
21
+ * ```
22
+ *
23
+ * @example
24
+ * Required number argument with alias:
25
+ * ```ts
26
+ * const schema: ArgSchema = {
27
+ * type: 'number',
28
+ * short: 'p',
29
+ * description: 'Port number to listen on',
30
+ * required: true
31
+ * }
32
+ * ```
33
+ *
34
+ * @example
35
+ * Enum argument with choices:
36
+ * ```ts
37
+ * const schema: ArgSchema = {
38
+ * type: 'enum',
39
+ * choices: ['info', 'warn', 'error'],
40
+ * description: 'Logging level',
41
+ * default: 'info'
42
+ * }
43
+ * ```
44
+ */
45
+ interface ArgSchema {
46
+ /**
47
+ * Type of the argument value.
48
+ *
49
+ * - `'string'`: Text value
50
+ * - `'boolean'`: `true`/`false` flag (can be negatable with `--no-` prefix). `--flag=true` and
51
+ * `--flag=false` set the value explicitly; any other value after `=` is a type error
52
+ * - `'number'`: Numeric value (parsed as integer or float)
53
+ * - `'enum'`: One of predefined string values (requires `choices` property, unless the argument
54
+ * has a `parse` function)
55
+ * - `'positional'`: Non-option argument by position
56
+ * - `'custom'`: Custom parsing with user-defined `parse` function
57
+ *
58
+ * Any other `type`, or no `type`, is a mistake that only untyped code can make: if the argument
59
+ * has no `parse` function, {@link resolveArgs} and `parse()` throw an `Error`, whether or not the
60
+ * argument is given.
61
+ *
62
+ * @example
63
+ * Different argument types:
64
+ * ```ts
65
+ * {
66
+ * name: { type: 'string' }, // --name value
67
+ * verbose: { type: 'boolean' }, // --verbose (--no-verbose needs negatable: true)
68
+ * port: { type: 'number' }, // --port 3000
69
+ * level: { type: 'enum', choices: ['debug', 'info'] },
70
+ * file: { type: 'positional' }, // first positional arg
71
+ * config: { type: 'custom', parse: JSON.parse }
72
+ * }
73
+ * ```
74
+ */
75
+ type: 'string' | 'boolean' | 'number' | 'enum' | 'positional' | 'custom';
76
+ /**
77
+ * Single character alias for the long option name.
78
+ *
79
+ * As example, allows users to use `-x` instead of `--extended-option`.
80
+ * Only valid for non-positional argument types.
81
+ *
82
+ * @example
83
+ * Short alias usage:
84
+ * ```ts
85
+ * {
86
+ * verbose: {
87
+ * type: 'boolean',
88
+ * short: 'v' // Enables both --verbose and -v
89
+ * },
90
+ * port: {
91
+ * type: 'number',
92
+ * short: 'p' // Enables both --port 3000 and -p 3000
93
+ * }
94
+ * }
95
+ * ```
96
+ */
97
+ short?: string;
98
+ /**
99
+ * Human-readable description of the argument's purpose.
100
+ *
101
+ * Used for help text generation and documentation.
102
+ * Should be concise but descriptive enough to understand the argument's role.
103
+ *
104
+ * @example
105
+ * Descriptive help text:
106
+ * ```ts
107
+ * {
108
+ * config: {
109
+ * type: 'string',
110
+ * description: 'Path to configuration file'
111
+ * },
112
+ * timeout: {
113
+ * type: 'number',
114
+ * description: 'Request timeout in milliseconds'
115
+ * }
116
+ * }
117
+ * ```
118
+ */
119
+ description?: string;
120
+ /**
121
+ * Hide the argument from generated help or usage output.
122
+ *
123
+ * This is metadata for renderers. It does not affect parsing, validation,
124
+ * required checks, defaults, conflicts, or resolved values.
125
+ *
126
+ * @example
127
+ * Hidden compatibility option:
128
+ * ```ts
129
+ * {
130
+ * legacy: {
131
+ * type: 'string',
132
+ * hidden: true,
133
+ * description: 'Deprecated compatibility option'
134
+ * }
135
+ * }
136
+ * ```
137
+ */
138
+ hidden?: boolean;
139
+ /**
140
+ * Marks the argument as required.
141
+ *
142
+ * When `true`, the argument must be provided by the user. If it is missing, the error is an
143
+ * `ArgResolveError` with type 'required' and the code `err:arg:required-option`
144
+ * ({@link ArgsValidationErrorKeys}.requiredOption), or `err:arg:required-positional`
145
+ * ({@link ArgsValidationErrorKeys}.requiredPositional) for a positional argument. Its
146
+ * `values` has the `displayName` as in the message, such as `'--name'` for an option
147
+ * (`'--name' or '-n'` with the short name `n`) and `'file'` for a positional argument, and
148
+ * the `name`, which is the schema key.
149
+ * An option that is given without a value is reported as `err:arg:missing-value`
150
+ * ({@link ArgsValidationErrorKeys}.missingValue) instead, because the option itself was given.
151
+ * An explicit empty value, such as `--name=` or `-n ''`, is still reported as required, but the
152
+ * option counts as given: it is `true` in `explicit` and takes part in conflicts, as any other
153
+ * given option does.
154
+ *
155
+ * For single-value positional arguments, omitting `required` keeps the argument required for
156
+ * compatibility, unless it has a `default`, which makes it optional. A `multiple` positional
157
+ * argument is optional unless `required: true` is set. Set `required: false` to make a positional
158
+ * argument explicitly optional. Optional positional arguments leave enough input values for later
159
+ * required positional arguments before consuming a value.
160
+ *
161
+ * In the type of the values, such as {@link ArgValues}, a single-value positional argument whose
162
+ * `required` may be `false`, such as one of type `boolean`, is optional, as it may be missing,
163
+ * unless it has a `default`. An optional `required` of type `boolean`, as declared here, says
164
+ * nothing, and keeps it required. TypeScript widens a `required: true` to `boolean` in a schema
165
+ * written apart from the call, such as `{ type: 'positional' as const, required: true }`, which
166
+ * types the argument as optional: write such a schema `as const`.
167
+ *
168
+ * @example
169
+ * Required arguments:
170
+ * ```ts
171
+ * {
172
+ * input: {
173
+ * type: 'string',
174
+ * required: true, // Must be provided: --input file.txt
175
+ * description: 'Input file path'
176
+ * },
177
+ * source: {
178
+ * type: 'positional',
179
+ * required: true // First positional argument must exist
180
+ * }
181
+ * }
182
+ * ```
183
+ */
184
+ required?: boolean;
185
+ /**
186
+ * Allows the argument to accept multiple values.
187
+ *
188
+ * When `true`, the resolved value becomes an array. When the argument is not given and has no
189
+ * `default`, its value is `undefined`, not an empty array.
190
+ * For options: can be specified multiple times (--tags foo --tags bar)
191
+ * For positional: collects remaining positional arguments after preserving values for
192
+ * later required positional arguments.
193
+ *
194
+ * Note: Only `true` is allowed (not `false`) to make intent explicit.
195
+ *
196
+ * @example
197
+ * Multiple values:
198
+ * ```ts
199
+ * {
200
+ * tags: {
201
+ * type: 'string',
202
+ * multiple: true, // --tags foo --tags bar → ['foo', 'bar']
203
+ * description: 'Tags to apply'
204
+ * },
205
+ * files: {
206
+ * type: 'positional',
207
+ * multiple: true // Collects all remaining positional args
208
+ * }
209
+ * }
210
+ * ```
211
+ */
212
+ multiple?: true;
213
+ /**
214
+ * Enables negation for boolean arguments using `--no-` prefix.
215
+ *
216
+ * When `true`, allows users to explicitly set the boolean to `false`
217
+ * using `--no-option-name`. When `false` or omitted, only positive
218
+ * form is available.
219
+ *
220
+ * Only applicable to `type: 'boolean'` arguments.
221
+ *
222
+ * The negated name is always `no-` followed by the full option name. An option named
223
+ * `no-cache` is negated by `--no-no-cache`, and `--no-cache` sets it to `true`.
224
+ *
225
+ * The negated form does not take a value. `--no-flag=<value>` is reported as
226
+ * `err:arg:unexpected-value` ({@link ArgsValidationErrorKeys}.unexpectedValue).
227
+ *
228
+ * @example
229
+ * Negatable boolean:
230
+ * ```ts
231
+ * {
232
+ * color: {
233
+ * type: 'boolean',
234
+ * negatable: true,
235
+ * default: true,
236
+ * description: 'Enable colorized output'
237
+ * }
238
+ * // Usage: --color (true), --no-color (false)
239
+ * }
240
+ * ```
241
+ */
242
+ negatable?: boolean;
243
+ /**
244
+ * Array of allowed string values for enum-type arguments.
245
+ *
246
+ * Required when `type: 'enum'`, unless the argument has a `parse` function: if `choices` is
247
+ * missing or not an array, {@link resolveArgs} and `parse()` throw a `TypeError`, whether or not
248
+ * the argument is given. With a `parse` function and no `choices`, the value is passed to the
249
+ * function as for a `string` argument with a `parse` function.
250
+ *
251
+ * The argument value must be one of these choices, otherwise the error is an `ArgResolveError`
252
+ * with type 'type' and the code `err:arg:invalid-choice`
253
+ * ({@link ArgsValidationErrorKeys}.invalidChoice).
254
+ *
255
+ * The value is checked before `parse` is called, so a `parse` function receives only one of
256
+ * these choices and can change it, for example to upper case.
257
+ *
258
+ * Supports both mutable arrays and readonly arrays for type safety.
259
+ *
260
+ * @example
261
+ * Enum choices:
262
+ * ```ts
263
+ * {
264
+ * logLevel: {
265
+ * type: 'enum',
266
+ * choices: ['debug', 'info', 'warn', 'error'] as const,
267
+ * default: 'info',
268
+ * description: 'Logging verbosity level'
269
+ * },
270
+ * format: {
271
+ * type: 'enum',
272
+ * choices: ['json', 'yaml', 'toml'],
273
+ * description: 'Output format'
274
+ * }
275
+ * }
276
+ * ```
277
+ */
278
+ choices?: string[] | readonly string[];
279
+ /**
280
+ * Default value used when the argument is not provided. An option that is given without a value,
281
+ * or with a value that is rejected, also gets its default, along with the error.
282
+ *
283
+ * The type must match the argument's `type` property:
284
+ * - `string` type: string default
285
+ * - `boolean` type: boolean default
286
+ * - `number` type: number default
287
+ * - `enum` type: one of the `choices` values, or a value that a `parse` of your own returns
288
+ * - `positional`/`custom` type: string, boolean, or number default
289
+ *
290
+ * The default is used as is and never goes through `parse`, including when an option is given
291
+ * without a value. An explicit empty value, such as `--name=` or `-n ''`, is a value, not a
292
+ * missing one: a `string` option without `parse` gets `''` instead of the default, unless it is
293
+ * `required`. What `parse` returns is a value too, even `undefined` or `null`, and the default
294
+ * does not replace it.
295
+ *
296
+ * The value of a `multiple` argument is an array, so its default becomes the only element of the
297
+ * array: `default: 'latest'` gives `['latest']`.
298
+ *
299
+ * The default of an `enum` option with `choices` is checked when it would be used, that is, when
300
+ * no value from the command line is used: one that is not one of the choices is reported as an
301
+ * `ArgResolveError` with type 'type' and the code `err:arg:invalid-default`
302
+ * ({@link ArgsValidationErrorKeys}.invalidDefault), and is not used. With a `parse` function of
303
+ * your own, or a `map()` transform, the default is a value that the function returns, which need
304
+ * not be one of the choices, and it is not checked. `choice()` returns the value as is, so its
305
+ * default is checked.
306
+ *
307
+ * For positional arguments, `multiple` ones included, the default is used when no value is left
308
+ * for the argument: when the positional values run out, or when the remaining ones are preserved
309
+ * for later required positional arguments. With `required: true`, the default is not used, and
310
+ * the argument is reported as required instead.
311
+ *
312
+ * @example
313
+ * Default values by type:
314
+ * ```ts
315
+ * {
316
+ * host: {
317
+ * type: 'string',
318
+ * default: 'localhost' // string default
319
+ * },
320
+ * verbose: {
321
+ * type: 'boolean',
322
+ * default: false // boolean default
323
+ * },
324
+ * port: {
325
+ * type: 'number',
326
+ * default: 8080 // number default
327
+ * },
328
+ * level: {
329
+ * type: 'enum',
330
+ * choices: ['low', 'high'],
331
+ * default: 'low' // must be in choices
332
+ * }
333
+ * }
334
+ * ```
335
+ */
336
+ default?: string | boolean | number;
337
+ /**
338
+ * Converts the argument name from camelCase to kebab-case for CLI usage.
339
+ *
340
+ * When `true`, a property like `maxCount` becomes available as `--max-count`.
341
+ * This allows [CAC](https://github.com/cacjs/cac) user-friendly property names while maintaining CLI conventions.
342
+ *
343
+ * The `toKebab` option of {@link resolveArgs} and `parse()` applies it to all arguments, as in
344
+ * `resolveArgs(args, tokens, { toKebab: true })`.
345
+ *
346
+ * Note: Only `true` is allowed (not `false`) to make intent explicit.
347
+ *
348
+ * @example
349
+ * Kebab-case conversion:
350
+ * ```ts
351
+ * {
352
+ * maxRetries: {
353
+ * type: 'number',
354
+ * toKebab: true, // Accessible as --max-retries
355
+ * description: 'Maximum retry attempts'
356
+ * },
357
+ * enableLogging: {
358
+ * type: 'boolean',
359
+ * toKebab: true // Accessible as --enable-logging
360
+ * }
361
+ * }
362
+ * ```
363
+ */
364
+ toKebab?: true;
365
+ /**
366
+ * Names of other options that conflict with this option.
367
+ *
368
+ * When this option is used together with any of the conflicting options, the error is an
369
+ * `ArgResolveError` with type 'conflict' and the code `err:arg:conflict`
370
+ * ({@link ArgsValidationErrorKeys}.conflict). Its `values` has the `displayName` and `name` of
371
+ * the argument whose `conflicts` names the other one, and the `conflictDisplayName` and
372
+ * `conflictName` of the other one. `displayName` and `conflictDisplayName` show an option as
373
+ * it was written, such as `'-p'`, and `name` and `conflictName` are the schema keys. When both
374
+ * name each other, `name` is the one that comes first in the schema.
375
+ *
376
+ * A positional argument can be on either side, such as `[file]` and `--stdin`. It conflicts when
377
+ * it is given, and it is shown by its name, such as `'file'` (in kebab-case with `toKebab`), as
378
+ * in its other errors. The message starts with the kind of the argument given as `name`:
379
+ * `Positional argument 'file' conflicts with '--stdin'` or
380
+ * `Optional argument '--stdin' conflicts with 'file'`.
381
+ *
382
+ * Conflicts only need to be defined on one side - if option A defines a conflict
383
+ * with option B, the conflict is automatically detected when both are used,
384
+ * regardless of whether B also defines a conflict with A.
385
+ *
386
+ * Supports both single option name or array of option names.
387
+ * Option names must match the property keys in the schema object exactly
388
+ * (no automatic conversion between camelCase and kebab-case).
389
+ *
390
+ * @example
391
+ * Single conflict (bidirectional definition):
392
+ * ```ts
393
+ * {
394
+ * summer: {
395
+ * type: 'boolean',
396
+ * conflicts: 'autumn' // Cannot use --summer with --autumn
397
+ * },
398
+ * autumn: {
399
+ * type: 'boolean',
400
+ * conflicts: 'summer' // Can define on both sides for clarity
401
+ * }
402
+ * }
403
+ * ```
404
+ *
405
+ * @example
406
+ * Single conflict (one-way definition):
407
+ * ```ts
408
+ * {
409
+ * summer: {
410
+ * type: 'boolean',
411
+ * conflicts: 'autumn' // Only defined on summer side
412
+ * },
413
+ * autumn: {
414
+ * type: 'boolean'
415
+ * // No conflicts defined, but still cannot use with --summer
416
+ * }
417
+ * }
418
+ * // Usage: --summer --autumn reports an error
419
+ * // Error: "Optional argument '--summer' conflicts with '--autumn'"
420
+ * ```
421
+ *
422
+ * @example
423
+ * Multiple conflicts:
424
+ * ```ts
425
+ * {
426
+ * port: {
427
+ * type: 'number',
428
+ * conflicts: ['socket', 'pipe'], // Cannot use with --socket or --pipe
429
+ * description: 'TCP port number'
430
+ * },
431
+ * socket: {
432
+ * type: 'string',
433
+ * conflicts: ['port', 'pipe'], // Cannot use with --port or --pipe
434
+ * description: 'Unix socket path'
435
+ * },
436
+ * pipe: {
437
+ * type: 'string',
438
+ * conflicts: ['port', 'socket'], // Cannot use with --port or --socket
439
+ * description: 'Named pipe path'
440
+ * }
441
+ * }
442
+ * // These three options are mutually exclusive
443
+ * ```
444
+ *
445
+ * @example
446
+ * With kebab-case conversion:
447
+ * ```ts
448
+ * {
449
+ * summerSeason: {
450
+ * type: 'boolean',
451
+ * toKebab: true, // Accessible as --summer-season
452
+ * conflicts: 'autumnSeason' // Must use property key, not CLI name
453
+ * },
454
+ * autumnSeason: {
455
+ * type: 'boolean',
456
+ * toKebab: true // Accessible as --autumn-season
457
+ * }
458
+ * }
459
+ * // Error: "Optional argument '--summer-season' conflicts with '--autumn-season'"
460
+ * ```
461
+ */
462
+ conflicts?: string | string[];
463
+ /**
464
+ * Display name hint for help text generation.
465
+ *
466
+ * Provides a meaningful type hint for the argument value in help output.
467
+ * Particularly useful for `type: 'custom'` arguments where the type
468
+ * name would otherwise be unhelpful.
469
+ *
470
+ * For a `custom` option that is given without a value, it is also the `expected` in the `values`
471
+ * of the `err:arg:missing-value` error ({@link ArgsValidationErrorKeys}.missingValue), which is
472
+ * `'custom'` without it.
473
+ *
474
+ * @example
475
+ * Metavar usage:
476
+ * ```ts
477
+ * {
478
+ * port: {
479
+ * type: 'custom',
480
+ * parse: (v: string) => parseInt(v, 10),
481
+ * metavar: 'integer',
482
+ * description: 'Port number (1-65535)'
483
+ * }
484
+ * }
485
+ * // Help output: --port <integer> Port number (1-65535)
486
+ * ```
487
+ */
488
+ metavar?: string;
489
+ /**
490
+ * Custom parsing function, used in place of the parsing of the argument's `type`.
491
+ *
492
+ * Required when `type: 'custom'`: if it is missing or not a function, {@link resolveArgs} and
493
+ * `parse()` throw a `TypeError`, whether or not the argument is given. The function receives the
494
+ * raw string value and must return the parsed result. It should throw an Error (or subclass) if
495
+ * parsing fails.
496
+ *
497
+ * The function's return type becomes the resolved argument type.
498
+ *
499
+ * `parse` is called synchronously, and what it returns becomes the value, even `undefined` or
500
+ * `null`, so throw to reject a value. An `async` function returns a promise, and the promise
501
+ * becomes the value as is: it is not awaited, and its rejection is not reported as an error.
502
+ * Await the value, or each of its elements with `multiple` (for example with `Promise.all()`),
503
+ * and handle the rejection yourself: a rejection that nothing handles is an unhandled rejection,
504
+ * which ends a Node.js process by default.
505
+ *
506
+ * A `boolean` option calls `parse` with `'true'`, or `'false'` for the negated form. Other
507
+ * options call it only with a value from the command line: when the option is given without a
508
+ * value, `parse` is not called and the missing value is reported as `err:arg:missing-value`
509
+ * ({@link ArgsValidationErrorKeys}.missingValue). When that error may suggest the argument after
510
+ * the option as its value, such as `--port=-5` for `--port -5`, a `parse` function of your own
511
+ * is not called to check it either: only the `parse` functions of `string()`, `number()`,
512
+ * `integer()`, `float()` and `choice()`, which have no side effects, are called for that.
513
+ * An explicit empty value, such as `--name=` or `-n=`, is passed as `''` unless
514
+ * `required: true` is set.
515
+ *
516
+ * An `enum` option with `choices` calls it only with one of them. Any other value, an explicit
517
+ * empty one included, is reported as `err:arg:invalid-choice`
518
+ * ({@link ArgsValidationErrorKeys}.invalidChoice), except that a required option reports an
519
+ * explicit empty value as required.
520
+ *
521
+ * @param value - Raw string value from command line
522
+ * @returns Parsed value of any type
523
+ * @throws {Error} Error or subclass when value is invalid
524
+ *
525
+ * @example
526
+ * Custom parsing functions:
527
+ * ```ts
528
+ * {
529
+ * config: {
530
+ * type: 'custom',
531
+ * parse: (value: string) => {
532
+ * try {
533
+ * return JSON.parse(value) // Parse JSON config
534
+ * } catch {
535
+ * throw new Error('Invalid JSON configuration')
536
+ * }
537
+ * },
538
+ * description: 'JSON configuration object'
539
+ * },
540
+ * date: {
541
+ * type: 'custom',
542
+ * parse: (value: string) => {
543
+ * const date = new Date(value)
544
+ * if (isNaN(date.getTime())) {
545
+ * throw new Error('Invalid date format')
546
+ * }
547
+ * return date
548
+ * }
549
+ * }
550
+ * }
551
+ * ```
552
+ */
553
+ parse?: (value: string) => any;
554
+ }
555
+ /**
556
+ * An object that contains {@link ArgSchema | argument schema}.
557
+ *
558
+ * This type is used to define the structure and validation rules for command line arguments.
559
+ */
560
+ interface Args$1 {
561
+ [option: string]: ArgSchema;
562
+ }
10
563
  //#endregion
11
564
  //#region ../shared/src/constants.d.ts
12
- /**
13
- * @author kazuya kawaguchi (a.k.a. kazupon)
14
- * @license MIT
15
- */
16
565
  /**
17
566
  * @author kazuya kawaguchi (a.k.a. kazupon)
18
567
  * @license MIT
@@ -35,36 +584,21 @@ type CommonArgType = {
35
584
  };
36
585
  declare const COMMON_ARGS: CommonArgType;
37
586
  declare const COMMAND_BUILTIN_RESOURCE_KEYS: readonly ["USAGE", "COMMAND", "SUBCOMMAND", "COMMANDS", "ARGUMENTS", "OPTIONS", "EXAMPLES", "FORMORE", "NEGATABLE", "DEFAULT", "CHOICES"];
38
- declare const ARG_ERROR_RESOURCE_KEYS: readonly ["err:arg:required-option", "err:arg:required-positional", "err:arg:invalid-type", "err:arg:invalid-choice", "err:arg:custom-parse", "err:arg:unknown-option"];
39
- declare const COMMAND_ERROR_RESOURCE_KEYS: readonly ["err:cmd:not-found"];
587
+ declare const ARG_ERROR_RESOURCE_KEYS: readonly ["err:arg:required-option", "err:arg:required-positional", "err:arg:invalid-type", "err:arg:invalid-choice", "err:arg:custom-parse", "err:arg:unknown-option", "err:arg:unexpected-value", "err:arg:missing-value", "err:arg:conflict", "err:arg:invalid-default"];
588
+ declare const ARG_ERROR_HINT_RESOURCE_KEYS: readonly ["err:arg:missing-value:hint"];
589
+ declare const COMMAND_ERROR_RESOURCE_KEYS: readonly ["err:cmd:not-found", "err:cmd:ambiguous", "err:cmd:inconsistent-options", "err:cmd:lazy-schema-mismatch"];
40
590
  declare const SUGGESTION_ERROR_RESOURCE_KEYS: readonly ["err:suggestion:did-you-mean"];
41
- //#endregion
42
- //#region ../plugin-i18n/src/types.d.ts
43
-
44
- //#endregion
45
- //#region ../plugin-i18n/src/helpers.d.ts
46
-
47
- //#endregion
48
- //#region ../plugin-i18n/src/translation.d.ts
49
-
50
- //#endregion
51
- //#region ../gunshi/src/index.d.ts
52
-
53
591
  //#endregion
54
592
  //#region ../shared/src/types.d.ts
55
- type RemoveIndexSignature<T> = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K] };
56
- /**
57
- * Make all properties in T deeply writeable (not readonly)
58
- */
59
-
593
+ type RemoveIndexSignature<T> = { [K in keyof T as string extends K ? never : number extends K ? never : K]: T[K]; };
60
594
  /**
61
595
  * Remove index signature from object or record type.
62
596
  */
63
- type RemovedIndex<T> = RemoveIndexSignature<{ [K in keyof T]: T[K] }>;
597
+ type RemovedIndex<T> = RemoveIndexSignature<{ [K in keyof T]: T[K]; }>;
64
598
  /**
65
599
  * Resolve a key on {@link Args}.
66
600
  */
67
- type KeyOfArgs<A extends Args$1> = keyof A | { [K in keyof A]: A[K]['type'] extends 'boolean' ? A[K]['negatable'] extends true ? `no-${Extract<K, string>}` : never : never }[keyof A];
601
+ type KeyOfArgs<A extends Args$1> = keyof A | { [K in keyof A]: A[K]['type'] extends 'boolean' ? A[K]['negatable'] extends true ? `no-${Extract<K, string>}` : never : never; }[keyof A];
68
602
  /**
69
603
  * Generate a namespaced key.
70
604
  */
@@ -81,6 +615,10 @@ type CommandBuiltinResourceKeys = (typeof COMMAND_BUILTIN_RESOURCE_KEYS)[number]
81
615
  * Args validation error resource keys.
82
616
  */
83
617
  type ArgErrorResourceKeys = (typeof ARG_ERROR_RESOURCE_KEYS)[number];
618
+ /**
619
+ * Resource keys of the hints that follow an args validation error.
620
+ */
621
+ type ArgErrorHintResourceKeys = (typeof ARG_ERROR_HINT_RESOURCE_KEYS)[number];
84
622
  /**
85
623
  * Command validation error resource keys.
86
624
  */
@@ -92,7 +630,7 @@ type SuggestionErrorResourceKeys = (typeof SUGGESTION_ERROR_RESOURCE_KEYS)[numbe
92
630
  /**
93
631
  * Error resource keys.
94
632
  */
95
- type ErrorResourceKeys = ArgErrorResourceKeys | CommandErrorResourceKeys | SuggestionErrorResourceKeys;
633
+ type ErrorResourceKeys = ArgErrorResourceKeys | ArgErrorHintResourceKeys | CommandErrorResourceKeys | SuggestionErrorResourceKeys;
96
634
  /**
97
635
  * Command built-in keys.
98
636
  */
@@ -107,22 +645,11 @@ type CommandArgKeys<A extends Args$1, C = {}, K extends string = GenerateNamespa
107
645
  /**
108
646
  * Resolve translation keys for command context.
109
647
  */
110
- type ResolveTranslationKeys<A extends Args$1, C = {},
111
- // for CommandContext
112
- E extends Record<string, string> = {},
113
- // for extended resources
114
- R extends string = keyof RemovedIndex<E>, T extends string = (C extends {
648
+ type ResolveTranslationKeys<A extends Args$1, C = {} // for CommandContext
649
+ , E extends Record<string, string> = {} // for extended resources
650
+ , R extends string = keyof RemovedIndex<E>, T extends string = C extends {
115
651
  name: infer N;
116
- } ? N extends string ? GenerateNamespacedKey<R, N> : R : R | CommandBuiltinKeys), O = CommandArgKeys<A, C>> = ErrorResourceKeys | CommandBuiltinKeys | O | T;
117
- //#endregion
118
- //#region ../shared/src/localization.d.ts
119
-
120
- //#endregion
121
- //#region ../shared/src/utils.d.ts
122
-
123
- //#endregion
124
- //#region ../shared/src/index.d.ts
125
-
652
+ } ? N extends string ? GenerateNamespacedKey<R, N> : R : R | CommandBuiltinKeys, O = CommandArgKeys<A, C>> = ErrorResourceKeys | CommandBuiltinKeys | O | T;
126
653
  //#endregion
127
654
  //#region src/types.d.ts
128
655
  /**
@@ -151,19 +678,29 @@ interface UsageRendererExtension<G extends GunshiParams<any> = DefaultGunshiPara
151
678
  * @param values - An optional record of values to interpolate into the translation string.
152
679
  * @returns The resolved translation string with interpolated values if provided.
153
680
  */
154
- text: <A extends Args = G['args'], C = {},
155
- // for CommandContext
156
- E extends Record<string, string> = {},
157
- // for extended resources
158
- K = ResolveTranslationKeys<A, C, E>>(key: K, values?: Record<string, unknown>) => Promise<string>;
681
+ text: <A extends Args = G['args'], C = {} // for CommandContext
682
+ , E extends Record<string, string> = {} // for extended resources
683
+ , K = ResolveTranslationKeys<A, C, E>>(key: K, values?: Record<string, unknown>) => Promise<string>;
159
684
  /**
160
685
  * Load commands
161
686
  *
162
687
  * @typeParam G - A type extending {@link GunshiParams} to specify the shape of command parameters.
163
688
  *
164
- * @returns A list of commands loaded from the usage renderer plugin.
689
+ * @returns A list of commands loaded from the usage renderer plugin. At the top-level entry,
690
+ * the list includes the CLI entry command even when a plugin registered the other commands
691
+ * without adding the entry to `env.subCommands`.
165
692
  */
166
693
  loadCommands: <G extends GunshiParams = DefaultGunshiParams>() => Promise<Command<G>[]>;
694
+ /**
695
+ * Resolve the description of a command in the command list.
696
+ *
697
+ * The description is localized when `@gunshi/plugin-i18n` is installed and the command has a
698
+ * resource of its own, and falls back to {@link Command.description} otherwise.
699
+ *
700
+ * @param cmd - A command from {@link UsageRendererExtension.loadCommands}.
701
+ * @returns The description to show for the command.
702
+ */
703
+ localizeCommandDescription: (cmd: Command) => Promise<string>;
167
704
  }
168
705
  //#endregion
169
706
  //#region src/header.d.ts
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { ANONYMOUS_COMMAND_NAME, ArgsValidationErrorKeys, isArgsValidationError, isCommandNotFoundError, plugin } from "@gunshi/plugin";
2
- //#region ../../node_modules/.pnpm/args-tokens@0.29.0/node_modules/args-tokens/lib/utils.js
1
+ import { ANONYMOUS_COMMAND_NAME, ArgsValidationErrorKeys, createCommandContext, isArgsValidationError, isCommandNotFoundError, isCommandResolutionError, plugin } from "@gunshi/plugin";
2
+ //#region ../../node_modules/.pnpm/args-tokens@1.0.0-beta.1/node_modules/args-tokens/lib/utils.js
3
3
  /**
4
4
  * Entry point of utils.
5
5
  *
@@ -30,14 +30,20 @@ function kebabnize(str) {
30
30
  * Check if the given command is a {@link LazyCommand}.
31
31
  *
32
32
  * @param cmd - A command to check
33
- * @returns `true` if the command is a {@link LazyCommand}, otherwise `false
33
+ * @returns `true` if the command is a {@link LazyCommand}, otherwise `false`
34
+ *
35
+ * A lazy command is identified by its `commandName` property. The property may be
36
+ * `undefined` when the lazy command has no definition name.
34
37
  */
35
38
  function isLazyCommand(cmd) {
36
- return typeof cmd === "function" && "commandName" in cmd && !!cmd.commandName;
39
+ return typeof cmd === "function" && "commandName" in cmd;
37
40
  }
38
41
  /**
39
42
  * Resolve a lazy command to a {@link Command}.
40
43
  *
44
+ * If the loader returns a command, the properties of the loaded command take precedence,
45
+ * and the properties that it does not define fall back to the definition that is given to `lazy`.
46
+ *
41
47
  * @param cmd - A {@link Commandable} or {@link LazyCommand} to resolve
42
48
  * @param name - Optional name of the command, if not provided, it will use the name from the command itself.
43
49
  * @param needRunResolving - Whether to run the resolving function of the lazy command.
@@ -53,6 +59,8 @@ async function resolveLazyCommand(cmd, name, needRunResolving = false) {
53
59
  examples: cmd.examples,
54
60
  internal: cmd.internal,
55
61
  entry: cmd.entry,
62
+ toKebab: cmd.toKebab,
63
+ rendering: cmd.rendering,
56
64
  subCommands: cmd.subCommands
57
65
  };
58
66
  if ("resource" in cmd && cmd.resource) baseCommand.resource = cmd.resource;
@@ -62,13 +70,20 @@ async function resolveLazyCommand(cmd, name, needRunResolving = false) {
62
70
  if (typeof loaded === "function") command.run = loaded;
63
71
  else if (typeof loaded === "object") {
64
72
  if (loaded.run == null) throw new TypeError(`'run' is required in command: ${cmd.name || name}`);
73
+ /**
74
+ * NOTE(kazupon): the loaded command wins, and the definition that is given to `lazy` is the fallback
75
+ * for what the loaded command does not define. A loader that returns `{ run }` keeps the whole definition,
76
+ * like a loader that returns the runner itself.
77
+ */
65
78
  command.run = loaded.run;
66
- command.name = loaded.name;
67
- command.description = loaded.description;
68
- command.args = loaded.args;
69
- command.examples = loaded.examples;
70
- command.internal = loaded.internal;
71
- command.entry = loaded.entry;
79
+ command.name = loaded.name ?? cmd.commandName;
80
+ command.description = loaded.description ?? cmd.description;
81
+ command.args = loaded.args ?? cmd.args;
82
+ command.examples = loaded.examples ?? cmd.examples;
83
+ command.internal = loaded.internal ?? cmd.internal;
84
+ command.entry = loaded.entry ?? cmd.entry;
85
+ command.toKebab = loaded.toKebab ?? cmd.toKebab;
86
+ command.rendering = loaded.rendering ?? cmd.rendering;
72
87
  command.subCommands = loaded.subCommands || cmd.subCommands;
73
88
  if ("resource" in loaded && loaded.resource) command.resource = loaded.resource;
74
89
  } else throw new TypeError(`Cannot resolve command: ${cmd.name || name}`);
@@ -138,12 +153,20 @@ var en_US_default = {
138
153
  help: "Display this help message",
139
154
  version: "Display this version",
140
155
  "err:arg:required-option": "Optional argument {$displayName} is required",
141
- "err:arg:required-positional": "Positional argument {$name} is required",
156
+ "err:arg:required-positional": "Positional argument {$displayName} is required",
142
157
  "err:arg:invalid-type": "Invalid value for {$displayName}: expected {$expected}",
143
158
  "err:arg:invalid-choice": "Invalid value for {$displayName}: expected one of {$choices}",
144
159
  "err:arg:custom-parse": "Invalid value for {$displayName}: {$reason}",
145
160
  "err:arg:unknown-option": "Unknown option: {$rawName}",
161
+ "err:arg:unexpected-value": "Optional argument {$displayName} does not take a value",
162
+ "err:arg:missing-value": "Optional argument {$displayName} requires a value",
163
+ "err:arg:missing-value:hint": "(to pass '{$next}' as its value, write '{$suggestion}')",
164
+ "err:arg:conflict": "{$displayName} conflicts with {$conflictDisplayName}",
165
+ "err:arg:invalid-default": "Invalid default for {$displayName}: {$actual} is not one of {$choices}",
146
166
  "err:cmd:not-found": "Command not found: {$commandName}",
167
+ "err:cmd:ambiguous": "The command is ambiguous. Move options after the command name: {$candidatePaths}",
168
+ "err:cmd:inconsistent-options": "The options do not identify a consistent command. Move options after the command name.",
169
+ "err:cmd:lazy-schema-mismatch": "The lazy command definition does not match its loaded options. Declare leading options in the lazy definition or move options after the command name: {$commandPath}",
147
170
  "err:suggestion:did-you-mean": "Did you mean {$name}?"
148
171
  };
149
172
  //#endregion
@@ -226,10 +249,36 @@ function namespacedId(id) {
226
249
  * @returns A string representing the short and long option pair.
227
250
  */
228
251
  function makeShortLongOptionPair(schema, name, toKebab) {
229
- let key = `--${toKebab || schema.toKebab ? kebabnize(name) : name}`;
252
+ let key = `--${resolveDisplayName(name, schema, toKebab)}`;
230
253
  if (schema.short) key = `-${schema.short}, ${key}`;
231
254
  return key;
232
255
  }
256
+ /**
257
+ * Resolve the name that an argument is parsed and rendered under.
258
+ *
259
+ * @param name - The key of the argument.
260
+ * @param schema - The {@linkcode ArgSchema | argument schema}.
261
+ * @param toKebab - Whether to convert the name to kebab-case.
262
+ * @returns The name of the argument, kebab-cased when `toKebab` asks for it.
263
+ */
264
+ function resolveDisplayName(name, schema, toKebab) {
265
+ return toKebab || schema.toKebab ? kebabnize(name) : name;
266
+ }
267
+ /**
268
+ * Collect the names that the arguments are parsed and rendered under.
269
+ *
270
+ * A positional argument is left out: it is never spelled as an option, so it cannot take the name of
271
+ * one. A `hidden` argument is counted, because it still owns the name that it parses.
272
+ *
273
+ * @param args - The {@linkcode Args | arguments} of a command, global options included.
274
+ * @param toKebab - Whether the command converts its names to kebab-case.
275
+ * @returns The set of names.
276
+ */
277
+ function resolveOptionNames(args, toKebab) {
278
+ const names = /* @__PURE__ */ new Set();
279
+ for (const [name, schema] of Object.entries(args)) if (schema.type !== "positional") names.add(resolveDisplayName(name, schema, toKebab));
280
+ return names;
281
+ }
233
282
  //#endregion
234
283
  //#region ../shared/src/localization.ts
235
284
  /**
@@ -257,15 +306,21 @@ function localizable(ctx, cmd, translate) {
257
306
  if (key.startsWith(ERROR_PREFIX_AND_KEY_SEPARATOR)) return resolveAndFormatResource(key, key, values);
258
307
  const namaspacedArgKey = resolveKey(ARG_PREFIX_AND_KEY_SEPARATOR, ctx.name);
259
308
  if (key.startsWith(namaspacedArgKey)) {
260
- let argKey = key.slice(namaspacedArgKey.length);
261
- let negatable = false;
309
+ const argKey = key.slice(namaspacedArgKey.length);
310
+ /**
311
+ * NOTE(kazupon): an argument describes itself, whatever it is called. `no-` is stripped only
312
+ * to reach the option that a negated form negates, and only when there is no argument under
313
+ * the name as written: `no-emoji` may be an argument of its own, and it then has nothing to do
314
+ * with `emoji`.
315
+ */
316
+ const ownSchema = ctx.args[argKey];
317
+ if (ownSchema) return ownSchema.description || "";
262
318
  if (argKey.startsWith("no-")) {
263
- argKey = argKey.slice(3);
264
- negatable = true;
319
+ const negatedKey = argKey.slice(3);
320
+ const schema = ctx.args[negatedKey];
321
+ if (schema && schema.type === "boolean" && schema.negatable) return `${en_US_default["NEGATABLE"]} ${makeShortLongOptionPair(schema, negatedKey, ctx.toKebab)}`;
265
322
  }
266
- const schema = ctx.args[argKey];
267
- if (!schema) return argKey;
268
- return negatable && schema.type === "boolean" && schema.negatable ? `${en_US_default["NEGATABLE"]} ${makeShortLongOptionPair(schema, argKey, ctx.toKebab)}` : schema.description || "";
323
+ return argKey;
269
324
  }
270
325
  if (key === resolveKey("description", ctx.name)) return "";
271
326
  else if (key === resolveKey("examples", ctx.name)) return await resolveExamples$1(ctx, cmd.examples);
@@ -321,6 +376,63 @@ function renderHeader(ctx) {
321
376
  */
322
377
  const pluginId = namespacedId("renderer");
323
378
  //#endregion
379
+ //#region src/width.ts
380
+ /**
381
+ * @author kazuya kawaguchi (a.k.a. kazupon)
382
+ * @license MIT
383
+ */
384
+ /**
385
+ * NOTE(kazupon): the columns of the usage are aligned by padding, and padding by `String#length`
386
+ * counts UTF-16 code units, which is not what a terminal draws: a full-width character takes two
387
+ * columns, a combining mark takes none, and a joined emoji takes two however many code points it is
388
+ * written with. The text is walked by grapheme, so that a mark or a joiner is part of the character
389
+ * it belongs to, and each grapheme is measured by the code point it starts with.
390
+ *
391
+ * JavaScript has no `\p{East_Asian_Width=Wide}` escape. Wide columns are those the engine already
392
+ * classifies as Han / Hiragana / Katakana / Hangul or emoji-presentation, plus the fullwidth slice
393
+ * of Halfwidth and Fullwidth Forms. That covers CLI names; the rest of the East Asian Width table
394
+ * is symbols that do not appear there. Unicode updates then come with the runtime.
395
+ */
396
+ const WIDE = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}\p{Emoji_Presentation}]/u;
397
+ const ZERO_WIDTH = /[\p{Default_Ignorable_Code_Point}\p{Mn}]/u;
398
+ function graphemeWidth(codePoint) {
399
+ const ch = String.fromCodePoint(codePoint);
400
+ if (ZERO_WIDTH.test(ch)) return 0;
401
+ /**
402
+ * Halfwidth and Fullwidth Forms: only the fullwidth slices are two columns. The rest of the
403
+ * block is halfwidth katakana / hangul, which `\p{Script=Katakana}` and `\p{Script=Hangul}`
404
+ * would otherwise count as wide.
405
+ */
406
+ if (codePoint >= 65280 && codePoint <= 65519) {
407
+ if (codePoint >= 65281 && codePoint <= 65376 || codePoint >= 65504 && codePoint <= 65510) return 2;
408
+ return 1;
409
+ }
410
+ return WIDE.test(ch) ? 2 : 1;
411
+ }
412
+ const segmenter = new Intl.Segmenter();
413
+ /**
414
+ * Measure the columns a terminal draws a string in.
415
+ *
416
+ * @param value - A string to measure
417
+ * @returns The number of columns
418
+ */
419
+ function displayWidth(value) {
420
+ let width = 0;
421
+ for (const { segment } of segmenter.segment(value)) width += graphemeWidth(segment.codePointAt(0));
422
+ return width;
423
+ }
424
+ /**
425
+ * Pad a string with spaces until it is drawn in the given number of columns.
426
+ *
427
+ * @param value - A string to pad
428
+ * @param width - The number of columns to pad it to
429
+ * @returns The padded string
430
+ */
431
+ function padEndToWidth(value, width) {
432
+ const padding = width - displayWidth(value);
433
+ return padding > 0 ? value + " ".repeat(padding) : value;
434
+ }
435
+ //#endregion
324
436
  //#region src/usage.ts
325
437
  /**
326
438
  * @author kazuya kawaguchi (a.k.a. kazupon)
@@ -331,10 +443,10 @@ function isHiddenArg(schema) {
331
443
  return schema.hidden === true;
332
444
  }
333
445
  function getVisibleOptionalArgs(args) {
334
- return Object.entries(args).filter(([_, schema]) => schema.type !== "positional" && !isHiddenArg(schema));
446
+ return Object.entries(args).filter(([, schema]) => schema.type !== "positional" && !isHiddenArg(schema));
335
447
  }
336
448
  function getVisiblePositionalArgs(args) {
337
- return Object.entries(args).filter(([_, schema]) => schema.type === "positional" && !isHiddenArg(schema));
449
+ return Object.entries(args).filter(([, schema]) => schema.type === "positional" && !isHiddenArg(schema));
338
450
  }
339
451
  /**
340
452
  * Render the usage.
@@ -428,18 +540,21 @@ async function makeUsageSymbols(ctx) {
428
540
  async function renderCommandsSection(ctx) {
429
541
  const messages = [`${await ctx.extensions[pluginId].text(resolveBuiltInKey("COMMANDS"))}:`];
430
542
  const loadedCommands = await ctx.extensions?.[pluginId].loadCommands() || [];
431
- const commandMaxLength = Math.max(...loadedCommands.map((cmd) => (cmd.name || "").length));
432
- const commandsStr = await Promise.all(loadedCommands.map(async (cmd) => {
433
- const desc = cmd.description || "";
434
- const optionSymbol = await generateOptionsSymbols(ctx, ctx.args);
435
- const positionalSymbol = generatePositionalSymbols(ctx.args);
436
- const commandStr = await makeCommandSymbol(ctx, cmd);
437
- const symbolLength = desc.length > 0 ? commandMaxLength + optionSymbol.length + positionalSymbol.length : 0;
438
- const command = `${commandStr.padEnd(symbolLength + ctx.env.middleMargin)}${desc}`;
543
+ const commandSymbols = await Promise.all(loadedCommands.map(async (cmd) => await makeCommandSymbol(ctx, cmd)));
544
+ const symbolMaxLength = Math.max(...commandSymbols.map((symbol) => displayWidth(symbol)));
545
+ const commandsStr = (await Promise.all(loadedCommands.map(async (cmd) => await ctx.extensions[pluginId].localizeCommandDescription(cmd)))).map((desc, index) => {
546
+ const commandStr = commandSymbols[index];
547
+ const command = desc ? `${padEndToWidth(commandStr, symbolMaxLength + ctx.env.middleMargin)}${desc}` : commandStr;
439
548
  return `${command.padStart(ctx.env.leftMargin + command.length)}`;
440
- }));
549
+ });
441
550
  messages.push(...commandsStr, "", `${await ctx.extensions[pluginId].text(resolveBuiltInKey("FORMORE"))}:`);
442
- const basePath = ctx.commandPath && ctx.commandPath.length > 0 ? `${ctx.env.name} ${ctx.commandPath.join(" ")}` : ctx.env.name;
551
+ /**
552
+ * NOTE(kazupon): the CLI is named the same way the usage line and the entry row of the section
553
+ * above name it, because `CliOptions.name` is optional and the block would otherwise invite the
554
+ * reader to run `undefined --help`.
555
+ */
556
+ const entry = await resolveEntry(ctx);
557
+ const basePath = ctx.commandPath && ctx.commandPath.length > 0 ? `${entry} ${ctx.commandPath.join(" ")}` : entry;
443
558
  messages.push(...loadedCommands.map((cmd) => {
444
559
  let commandStr = cmd.entry ? "" : cmd.name || "";
445
560
  if (commandStr) commandStr += " ";
@@ -449,9 +564,15 @@ async function renderCommandsSection(ctx) {
449
564
  return messages;
450
565
  }
451
566
  async function makeCommandSymbol(ctx, cmd) {
452
- const optionSymbol = await generateOptionsSymbols(ctx, ctx.args);
453
- const positionalSymbol = generatePositionalSymbols(ctx.args);
454
- let commandStr = cmd.entry ? cmd.name === void 0 || cmd.name === ANONYMOUS_COMMAND_NAME ? "" : `[${cmd.name}]` : cmd.name || "";
567
+ /**
568
+ * NOTE(kazupon): the row describes `cmd`, so its symbols come from what `cmd` declares.
569
+ * A lazy command is described by the definition that is given to `lazy`, since the command
570
+ * list does not run the loader.
571
+ */
572
+ const args = cmd.args || {};
573
+ const optionSymbol = await generateOptionsSymbols(ctx, args);
574
+ const positionalSymbol = generatePositionalSymbols(args);
575
+ let commandStr = cmd.entry ? cmd.name === void 0 || cmd.name === ANONYMOUS_COMMAND_NAME ? `[${await resolveEntry(ctx)}]` : `[${cmd.name}]` : cmd.name || "";
455
576
  if (optionSymbol) {
456
577
  if (commandStr) commandStr += " ";
457
578
  commandStr += `${optionSymbol}`;
@@ -537,7 +658,11 @@ function hasPositionalArgs(args) {
537
658
  */
538
659
  function hasAllDefaultOptions(args) {
539
660
  const visibleOptionalArgs = getVisibleOptionalArgs(args);
540
- return visibleOptionalArgs.length > 0 && visibleOptionalArgs.every(([_, arg]) => arg.default);
661
+ /**
662
+ * NOTE(kazupon): an argument has a default when one is declared, whatever its value. `0`, `''` and
663
+ * `false` are defaults like any other, and an option that has one is not required (#750).
664
+ */
665
+ return visibleOptionalArgs.length > 0 && visibleOptionalArgs.every(([, arg]) => arg.default !== void 0);
541
666
  }
542
667
  /**
543
668
  * Generate options symbols for usage
@@ -556,23 +681,32 @@ async function generateOptionsSymbols(ctx, args) {
556
681
  * @returns Options pairs for usage
557
682
  */
558
683
  function getOptionalArgsPairs(ctx) {
684
+ /**
685
+ * NOTE(kazupon): an argument owns the name that it renders under, so the negated form of another
686
+ * option must not be listed under it (#732). The names are compared as they are rendered, because
687
+ * `toKebab` can bring two different keys under one name (#743). A `hidden` argument counts: it is
688
+ * not listed, and neither is a negated form that would take its name.
689
+ */
690
+ const optionNames = resolveOptionNames(ctx.args, ctx.toKebab);
559
691
  return getVisibleOptionalArgs(ctx.args).reduce((acc, [name, schema]) => {
692
+ const displayName = resolveDisplayName(name, schema, ctx.toKebab);
560
693
  let key = makeShortLongOptionPair(schema, name, ctx.toKebab);
561
- if (schema.type !== "boolean") {
562
- const displayName = ctx.toKebab || schema.toKebab ? kebabnize(name) : name;
563
- key = schema.default ? `${key} [${displayName}]` : `${key} <${displayName}>`;
564
- }
694
+ if (schema.type !== "boolean") key = schema.default === void 0 ? `${key} <${displayName}>` : `${key} [${displayName}]`;
565
695
  acc[name] = key;
566
696
  if (schema.type === "boolean" && schema.negatable && !COMMON_ARGS_KEYS.includes(name)) {
567
- const displayName = ctx.toKebab || schema.toKebab ? kebabnize(name) : name;
568
- acc[`no-${name}`] = `--no-${displayName}`;
697
+ const negatedName = `no-${displayName}`;
698
+ if (!optionNames.has(negatedName)) acc[`no-${name}`] = `--${negatedName}`;
569
699
  }
570
700
  return acc;
571
701
  }, Object.create(null));
572
702
  }
573
- const resolveNegatableKey = (key) => key.split("no-")[1];
703
+ const resolveNegatableKey = (key) => key.slice(3);
574
704
  function resolveNegatableType(key, ctx) {
575
- return ctx.args[key.startsWith("no-") ? resolveNegatableKey(key) : key].type;
705
+ /**
706
+ * NOTE(kazupon): the key of an argument wins over the option that a negated form negates, and a
707
+ * negated form is a boolean flag whether or not the option it negates can be reached from here.
708
+ */
709
+ return (ctx.args[key] ?? ctx.args[resolveNegatableKey(key)])?.type ?? "boolean";
576
710
  }
577
711
  async function generateDefaultDisplayValue(ctx, schema) {
578
712
  return `${await ctx.extensions[pluginId].text(resolveBuiltInKey("DEFAULT"))}: ${schema.default}`;
@@ -598,21 +732,26 @@ async function resolveDisplayValue(ctx, key) {
598
732
  async function generateOptionalArgsUsage(ctx, optionsPairs) {
599
733
  const optionsPairsEntries = Object.entries(optionsPairs);
600
734
  if (optionsPairsEntries.length === 0) return "";
601
- const optionsMaxLength = Math.max(...optionsPairsEntries.map(([_, value]) => value.length));
602
- const optionSchemaMaxLength = ctx.env.usageOptionType ? Math.max(...optionsPairsEntries.map(([key]) => resolveNegatableType(key, ctx).length)) : 0;
735
+ const optionsMaxLength = Math.max(...optionsPairsEntries.map(([, value]) => displayWidth(value)));
736
+ const optionSchemaMaxLength = ctx.env.usageOptionType ? Math.max(...optionsPairsEntries.map(([key]) => displayWidth(resolveNegatableType(key, ctx)))) : 0;
603
737
  return (await Promise.all(optionsPairsEntries.map(async ([key, value]) => {
604
738
  let rawDesc = await ctx.extensions[pluginId].text(resolveArgKey(key, ctx.name));
605
- if (!rawDesc && key.startsWith("no-")) {
739
+ if (!rawDesc && !ctx.args[key] && key.startsWith("no-")) {
740
+ /**
741
+ * NOTE(kazupon): only a key that no argument answers to may be read as the negated form of
742
+ * another option, and only when that option is there to be negated.
743
+ */
606
744
  const name = resolveNegatableKey(key);
607
745
  const schema = ctx.args[name];
608
- const optionKey = makeShortLongOptionPair(schema, name, ctx.toKebab);
609
- rawDesc = `${await ctx.extensions[pluginId].text(resolveBuiltInKey("NEGATABLE"))} ${optionKey}`;
746
+ if (schema) {
747
+ const optionKey = makeShortLongOptionPair(schema, name, ctx.toKebab);
748
+ rawDesc = `${await ctx.extensions[pluginId].text(resolveBuiltInKey("NEGATABLE"))} ${optionKey}`;
749
+ }
610
750
  }
611
751
  const optionsSchema = ctx.env.usageOptionType ? `[${resolveNegatableType(key, ctx)}] ` : "";
612
752
  const valueDesc = key.startsWith("no-") ? "" : await resolveDisplayValue(ctx, key);
613
- const desc = `${optionsSchema ? optionsSchema.padEnd(optionSchemaMaxLength + 3) : ""}${rawDesc}`;
614
- const descLength = desc.length + valueDesc.length;
615
- const option = `${value.padEnd((descLength > 0 ? optionsMaxLength : 0) + ctx.env.middleMargin)}${desc}${valueDesc ? ` ${valueDesc}` : ""}`;
753
+ const desc = `${optionsSchema ? padEndToWidth(optionsSchema, optionSchemaMaxLength + 3) : ""}${rawDesc}`;
754
+ const option = `${padEndToWidth(value, (desc.length + valueDesc.length > 0 ? optionsMaxLength : 0) + ctx.env.middleMargin)}${desc}${valueDesc ? ` ${valueDesc}` : ""}`;
616
755
  return `${option.padStart(ctx.env.leftMargin + option.length)}`;
617
756
  }))).join("\n");
618
757
  }
@@ -622,10 +761,10 @@ function getPositionalArgs(args) {
622
761
  async function generatePositionalArgsUsage(ctx) {
623
762
  const positionals = getPositionalArgs(ctx.args);
624
763
  if (positionals.length === 0) return "";
625
- const argsMaxLength = Math.max(...positionals.map(([name]) => name.length));
764
+ const argsMaxLength = Math.max(...positionals.map(([name]) => displayWidth(name)));
626
765
  return (await Promise.all(positionals.map(async ([name]) => {
627
766
  const desc = await ctx.extensions[pluginId].text(resolveArgKey(name, ctx.name)) || ctx.args[name].description || "";
628
- const arg = `${name.padEnd(argsMaxLength + ctx.env.middleMargin)} ${desc}`;
767
+ const arg = `${padEndToWidth(name, argsMaxLength + ctx.env.middleMargin)} ${desc}`;
629
768
  return `${arg.padStart(ctx.env.leftMargin + arg.length)}`;
630
769
  }))).join("\n");
631
770
  }
@@ -652,6 +791,7 @@ function isRequiredSinglePositionalArg(arg) {
652
791
  * @license MIT
653
792
  */
654
793
  const i18nPluginId$1 = namespacedId("i18n");
794
+ const missingValueHintKey = "err:arg:missing-value:hint";
655
795
  /**
656
796
  * Render the validation errors.
657
797
  *
@@ -669,13 +809,38 @@ async function renderValidationError(ctx, error) {
669
809
  const message = await localize(ctx, error.code, error.values);
670
810
  if (message && message !== error.code) return message;
671
811
  }
812
+ if (isCommandResolutionError(error) && error.code) {
813
+ const message = await localize(ctx, error.code, error.values);
814
+ if (message && message !== error.code) return message;
815
+ }
672
816
  if (isArgsValidationError(error) && error.code) {
673
817
  const values = await resolveValidationValues(ctx, error);
674
818
  const message = await localize(ctx, error.code, values);
675
- if (message && message !== error.code) return message;
819
+ if (message && message !== error.code) {
820
+ const hint = await localizeHint(ctx, error, values);
821
+ return hint ? `${message} ${hint}` : message;
822
+ }
676
823
  }
677
824
  return error.message;
678
825
  }
826
+ /**
827
+ * Localize the hint that args-tokens adds to the message of an error.
828
+ *
829
+ * A translation replaces the whole message, hint included, so the hint is translated by a key of
830
+ * its own and follows the translated error. args-tokens gives one today: for an option given without
831
+ * a value, when the argument after it may be the value, the long form with `=` (`--port=-5` for
832
+ * `--port -5`), in `values.next` and `values.suggestion`.
833
+ *
834
+ * @param ctx - A {@link CommandContext | command context}
835
+ * @param error - An args validation error
836
+ * @param values - The values of the error, as given to its translation
837
+ * @returns The localized hint, or `undefined` when the error has none.
838
+ */
839
+ async function localizeHint(ctx, error, values) {
840
+ if (error.code !== ArgsValidationErrorKeys.missingValue || typeof values.next !== "string" || typeof values.suggestion !== "string") return;
841
+ const hint = await localize(ctx, missingValueHintKey, values);
842
+ return hint && hint !== missingValueHintKey ? hint : void 0;
843
+ }
679
844
  async function resolveValidationValues(ctx, error) {
680
845
  if (error.code !== ArgsValidationErrorKeys.customParse) return error.values;
681
846
  const { reasonKey, reasonValues } = error.values;
@@ -748,7 +913,10 @@ function renderer() {
748
913
  async function loadCommands() {
749
914
  if (cachedCommands) return cachedCommands;
750
915
  const subCommands = [...ctx.env.subCommands || []];
751
- cachedCommands = (await Promise.all(subCommands.map(async ([name, cmd]) => await resolveLazyCommand(cmd, name)))).filter((cmd) => !cmd.internal).filter(Boolean);
916
+ const allCommands = await Promise.all(subCommands.map(async ([name, cmd]) => await resolveLazyCommand(cmd, name)));
917
+ const entryCommand = ctx.env.entryCommand;
918
+ if (ctx.callMode === "entry" && subCommands.length > 0 && entryCommand && !subCommands.some(([, command]) => command === entryCommand)) allCommands.push(await resolveLazyCommand(entryCommand, ANONYMOUS_COMMAND_NAME));
919
+ cachedCommands = allCommands.filter((cmd) => !cmd.internal).filter(Boolean);
752
920
  cachedCommands.sort((a, b) => {
753
921
  if (a.entry && !b.entry) return -1;
754
922
  if (!a.entry && b.entry) return 1;
@@ -759,9 +927,33 @@ function renderer() {
759
927
  });
760
928
  return cachedCommands;
761
929
  }
930
+ /**
931
+ * Resolve the description of a command in the command list.
932
+ *
933
+ * NOTE(kazupon): the description is localized the way the command's own help localizes it,
934
+ * which needs that command's resource. `text()` is bound to the command that is running, so it
935
+ * cannot answer for another one. The i18n plugin loads a resource into the adapter under the
936
+ * name of the command it is given, and `translate()` reads it back from there (#748).
937
+ *
938
+ * @param target - A command from `loadCommands`
939
+ * @returns The description to show for the command
940
+ */
941
+ async function localizeCommandDescription(target) {
942
+ const fallback = target.description || "";
943
+ if (!i18n || !target.name) return fallback;
944
+ const commandCtx = await createCommandContext({
945
+ args: target.args,
946
+ command: target,
947
+ callMode: target.entry ? "entry" : "subCommand",
948
+ extensions: {}
949
+ });
950
+ await i18n.loadResource(i18n.locale, commandCtx, target);
951
+ return await i18n.translate(resolveKey("description", target.name)) || fallback;
952
+ }
762
953
  return {
763
954
  text: localizable(ctx, cmd, i18n?.translate),
764
- loadCommands
955
+ loadCommands,
956
+ localizeCommandDescription
765
957
  };
766
958
  },
767
959
  setup: (ctx) => {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@gunshi/plugin-renderer",
3
3
  "description": "usage renderer plugin for gunshi",
4
- "version": "0.37.3",
4
+ "version": "1.0.0-beta.1",
5
5
  "author": {
6
6
  "name": "kazuya kawaguchi",
7
7
  "email": "kawakazu80@gmail.com"
@@ -53,18 +53,18 @@
53
53
  }
54
54
  },
55
55
  "dependencies": {
56
- "@gunshi/plugin": "0.37.3"
56
+ "@gunshi/plugin": "1.0.0-beta.1"
57
57
  },
58
58
  "peerDependencies": {
59
- "@gunshi/plugin-i18n": "0.37.3"
59
+ "@gunshi/plugin-i18n": "1.0.0-beta.1"
60
60
  },
61
61
  "devDependencies": {
62
+ "@gunshi/shared": "1.0.0-beta.1",
62
63
  "deno": "^2.7.14",
63
64
  "jsr": "^0.14.3",
64
65
  "jsr-exports-lint": "^0.4.2",
65
66
  "publint": "^0.3.20",
66
- "tsdown": "0.21.0",
67
- "@gunshi/shared": "0.37.3"
67
+ "tsdown": "0.22.14"
68
68
  },
69
69
  "scripts": {
70
70
  "build": "tsdown",